diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f0d006 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# 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 diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..b95fa4d --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + - platform: web + create_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + base_revision: adc901062556672b4138e18a4dc62a4be8f4b3c2 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/ABOUT_MILER.md b/ABOUT_MILER.md new file mode 100644 index 0000000..5829ae6 --- /dev/null +++ b/ABOUT_MILER.md @@ -0,0 +1,682 @@ +# Miler — the Doormile rider app + +> *Miler Rider — Smarter Pickups, Powered by AI* +> Flutter · Android + iOS · `com.doormile.partner` · v1.2.28+144 + +This is the single reference for what this app is, who it serves, how it is built, +and how it was designed. If you are new to the codebase, read sections 1–3. If you +are about to change UI, read sections 5–7 before you write a line. + +--- + +## Contents + +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) +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) + +--- + +## 1. What Miler is + +Miler is the **rider app** built by **Doormile**, a logistics company that moves +parcels between customers and its hubs. It is the tool carried by the field agent +we call a **"miler"** — the person on a bike who actually collects and delivers +packages on the ground. + +The system works like this. Customers use the separate **Doormile customer app** +to book a **time slot** — a window in which they want a parcel picked up or +delivered. Behind the scenes, for each slot, a **hub manager (admin)** gathers all +the customer bookings falling in that window and **assigns them to one miler as a +single ordered route** — Stop 1, Stop 2, Stop 3, through to the last stop. That +route appears inside this app in the exact sequence the admin set. + +The miler goes **On Duty**, works the stops one by one in order, and at each stop +does either a pickup or a delivery or both : + +- **Pickup** — collects the parcel from the customer or store, photographs it, + takes payment if money is owed (cash or UPI/QR), confirms it as picked up. +- **Delivery** — hands over a parcel carried from the hub, verifies with an **OTP + and a photo**, confirms it as delivered. Deliveries are prepaid, so no money + is collected. + +He can **skip** a stop when a store is closed or a customer isn't available, and +come back to it later. He **cannot reorder** the route — the sequence is fixed by +the admin. After the last stop he **returns to the same hub he started from**, +bringing back everything he collected plus anything he couldn't deliver. + +In short: a **first-mile + last-mile round trip**, `Hub → stop 1 → stop 2 → … → +stop N → back to the same Hub`, much like an Amazon or Flipkart delivery associate +runs a route. This app is the miler's companion for the whole journey. + +### Doormile's two apps + +| App | Who uses it | What they do | +|---|---|---| +| **Doormile customer app** | End customers | Book a time slot for a pickup or delivery | +| **Miler app** (this one) | The rider | Carry out the assigned route | + +### Who this app is for + +One person, on a bike, working through an assigned list of stops for a shift. +That is the entire user base. Every design decision in this document traces back +to that single user and one fact about him: **the daily loop repeats 30–50 times a +day.** + +--- + +## 2. The people and the workflow + +| Role | What they do | Which app | +|---|---|---| +| **Customer (CX)** | Books a time slot for a pickup or delivery | Doormile customer app | +| **Hub manager / Admin** | Bundles a slot's bookings into an ordered route and assigns it to a miler | Admin / hub system | +| **Miler (rider)** | Runs the route — pickups and deliveries — and returns to the hub | **This app** | + +### The loop, step by step + +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**: + +```dart +{'outcome': 'completed'|'cancelled', 'isDelivery': bool, + 'bonusPoints': int, 'paymentMethod': String} +// skip pops `true`; dismiss pops `null` +``` + +The **caller** — `map.dart`'s `_startPickupNavigation`, which is a normal page +route — inspects the result and does +`Navigator.pushReplacement(context, PickupsDone(...))` from its own route context +(`map.dart:788`). It also removes the finished stop from the list first, so it +doesn't linger as "active" blocking the next stop. This is what makes "Move to +next stop" appear after picked up / delivered / cancelled. + +Other flow decisions: no-payment pickups skip the payment-method list entirely +(the reason list only shows for cancel or pickup-with-collection). The signature +step was dropped for pickups — photo plus weight is enough proof. Test pre-fills +in `pickup_verify.dart` were removed. + +### Earnings — `views/Dashboard/summary/summary.dart` + +Pickup counts, success rate, reward points, distance ridden. The hero count is +wrapped in an `AnimatedSwitcher` (fade + scale) so switching period animates +rather than cutting. + +### Account — `views/Dashboard/profile/` + +Rider details, support tickets, FAQ, notifications, alert sound, saved addresses, +rewards, terms. Page-entrance `Reveal`; `PressScale` on the tile rows. + +### Offline — `views/offline/offline_page.dart` + +A full-screen state with a breathing halo, stacked over the app whenever +connectivity drops. Retry routes back through `AppBootstrap`, which re-decides +login vs. home from scratch. + +--- + +## 8. Backend integration and known gaps + +The app targets the **new v1 backend** — `https://api.doormile.com/api/v1`, +Bearer-token auth, `{success, data, message}` envelopes, camelCase — alongside the +legacy jupiter/workolik backend. + +**Strategy: adapter + env flag.** `lib/data/api_config.dart` is the one switch and +mapper: + +- `ApiConfig.useNewApi` — defaults **true**; override with + `--dart-define=USE_NEW_API=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.** + +Two mock layers had to be disabled for live data: `getMockQueues()` / +`getMockBookingPageItems()` fallbacks (now gated behind `!useNewApi`), and the +auth controller, which used to fake a Demo Rider (userid 9999) and bypass the +server entirely. In live mode `verifyPinWithServer` now does a real +`POST /miler/verify-pin` and succeeds only on a server OK plus a stored token. + +**The pasted API doc was inaccurate** (verified live 2026-07-18). `verify-pin` +returns the login under **`user` / `user.profile`**, not `data`. The mapper reads +the real shape. Booking field names are still based on the unreliable doc — +**re-verify the `pickupFromBooking` mapping once a real booking is assigned.** + +`AssignmentLookup` (`lib/data/assignment_lookup.dart`) exists because of one of +these mismatches: `POST /miler/assignments/{id}/accept` keys on +`bookingassignmentid`, but everything the app renders comes from +`GET /miler/bookings`, whose rows only carry `bookingid`. Different sequences, +different values. Sending a booking id made the backend answer 404 while the rider +saw success and the booking was never actually accepted. `GET /miler/assignments` +is the only place the pairing is exposed, so the app fetches it and keeps a +short-lived map. + +### Known backend gaps + +The app degrades gracefully around all of these and logs `[API_GAP]`: + +- no skip / resume endpoint +- no booking-cancel endpoint +- **no per-stop `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`) + +### Release safety + +Demo and test toggles are tied to `kDebugMode` so they auto-disable in release +builds: `kBypassGeofenceForTesting` (`pickups_controller.dart`), `kDemoMixedRoute` +(`stop_type.dart`), and the summary weekly-kms mock fallback. + +--- + +## 9. What we are doing with this app now + +The programme is one thing: **make the app simple enough that a rider running it +30–50 times a day never has to think about it.** The app was already +well-built — branded app bars, a coherent maroon palette, a premium Earnings hero, +clean cards. It did not need a layout rewrite. It needed clarity, motion, and +honest data. + +### Done + +- **Terminology and IA** — one noun set (Route / Stops / Hub). Home = new stops + and route start; Bookings = the active route. +- **Typeface** — Plus Jakarta Sans → **Manrope**, verified on device. +- **Semantic colour consolidation** — four greens → one `acceptGreen`; roles + documented and applied. +- **Slide fatigue killed** — start-pickup and arrived became taps; the confirm + sheet's hidden swipe became three visible buttons. Slides remain only on payment + and Off Duty. +- **Motion layer** — `Reveal`, `PressScale`, shimmer skeletons; staggered lists on + Bookings, Home and notifications; `Transition.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`. + +### Next + +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. + +--- + +## 10. Conventions for anyone touching this code + +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. + +--- + +## The core idea, simply put + +**A miler is handed a fixed, ordered route for a time slot, does every pickup and +delivery on it in sequence, and brings everything back to the hub he started +from — and this app guides him through every stop of that journey.** + +Everything else in this document is in service of making that loop cost him as +little attention as possible, fifty times a day. diff --git a/API_SPEC.md b/API_SPEC.md new file mode 100644 index 0000000..95fb47d --- /dev/null +++ b/API_SPEC.md @@ -0,0 +1,400 @@ +# 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/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..b79e1e0 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,32 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +analyzer: + errors: + file_names: ignore + unused_field: ignore +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..fd764dc --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore + + diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..63063b9 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,124 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" + id "com.google.gms.google-services" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader("UTF-8") { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty("flutter.versionCode") ?: "1" +def flutterVersionName = localProperties.getProperty("flutter.versionName") ?: "1.0" + +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +// No maps key is read here. The Google Maps SDK is gone: tiles come from +// OpenStreetMap and routes from OSRM, both keyless, drawn by flutter_map inside +// the Flutter view. The key this block used to inject was revoked, which is why +// the map rendered as a blank grey grid. + +android { + namespace = "com.doormile.partner" + compileSdk = 36 + + // Correct NDK version + ndkVersion "27.0.12077973" + + packagingOptions { + jniLibs { + useLegacyPackaging = true + } + // ❌ removed doNotStrip (this was breaking your AAB build) + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled true + } + + kotlinOptions { + jvmTarget = "1.8" + } + + defaultConfig { + applicationId = "com.doormile.partner" + + minSdkVersion flutter.minSdkVersion + targetSdkVersion 36 + + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + + manifestPlaceholders = [ + applicationName: "android.app.Application" + ] + + // Build only ARM64 + ndk { + abiFilters "arm64-v8a" + } + } + + signingConfigs { + release { + keyAlias keystoreProperties["keyAlias"] + keyPassword keystoreProperties["keyPassword"] + storeFile keystoreProperties["storeFile"] ? file(keystoreProperties["storeFile"]) : null + storePassword keystoreProperties["storePassword"] + } + } + + buildTypes { + debug { + minifyEnabled false + debuggable true + } + + release { + signingConfig signingConfigs.release + minifyEnabled true + shrinkResources true + + proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + + // ❌ removed ndk { debugSymbolLevel } (this caused strip crash) + } + } +} + +dependencies { + implementation "androidx.core:core:1.10.0" + implementation "com.google.android.gms:play-services-location:21.0.1" + coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.1.5" +} + +flutter { + source = "../.." +} + +tasks.register("copyNotificationSounds", Copy) { + def audioDir = file("$projectDir/../../assets/audio") + from(audioDir) + include("*.mp3") + into("$projectDir/src/main/res/raw") + + rename { String fileName -> + fileName.replaceAll(/[^A-Za-z0-9_.]/, "_").toLowerCase() + } +} + +tasks.named("preBuild").configure { + dependsOn("copyNotificationSounds") +} diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..d80ed63 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,163 @@ +{ + "project_info": { + "project_number": "140444764229", + "firebase_url": "https://doormile-gear-default-rtdb.firebaseio.com", + "project_id": "doormile-gear", + "storage_bucket": "doormile-gear.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:140444764229:android:a584c5e9127c3789283b2c", + "android_client_info": { + "package_name": "com.doormile.admin" + } + }, + "oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBkzz2Yua74Q9YpzGmUPFP94fmJQqNMIiU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "140444764229-m6l2v6eofrb9jgrno3qmsrjbtd4iccvd.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.doormile.gear" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:140444764229:android:a99eaae950fa5ada283b2c", + "android_client_info": { + "package_name": "com.doormile.bond" + } + }, + "oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBkzz2Yua74Q9YpzGmUPFP94fmJQqNMIiU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "140444764229-m6l2v6eofrb9jgrno3qmsrjbtd4iccvd.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.doormile.gear" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:140444764229:android:88df9627e2990ef4283b2c", + "android_client_info": { + "package_name": "com.doormile.gear" + } + }, + "oauth_client": [ + { + "client_id": "140444764229-5cllr5f8u28psf8ttc220f6h487u9vjq.apps.googleusercontent.com", + "client_type": 1, + "android_info": { + "package_name": "com.doormile.gear", + "certificate_hash": "1d8115901ded2af41250ffbfe99b9239a1953c05" + } + }, + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBkzz2Yua74Q9YpzGmUPFP94fmJQqNMIiU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "140444764229-m6l2v6eofrb9jgrno3qmsrjbtd4iccvd.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.doormile.gear" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:140444764229:android:578383f5a1d3a05c283b2c", + "android_client_info": { + "package_name": "com.doormile.partner" + } + }, + "oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyBkzz2Yua74Q9YpzGmUPFP94fmJQqNMIiU" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "140444764229-seu4nkl2k2hj6gebk3q20sv80k685ecp.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "140444764229-m6l2v6eofrb9jgrno3qmsrjbtd4iccvd.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "com.doormile.gear" + } + } + ] + } + } + } + ], + "configuration_version": "1" +} diff --git a/android/app/nearlerider-keystore.jks b/android/app/nearlerider-keystore.jks new file mode 100644 index 0000000..7f4d142 Binary files /dev/null and b/android/app/nearlerider-keystore.jks differ diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..138d106 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,89 @@ +########################################## +## ✅ FLUTTER +########################################## +-keep class io.flutter.** { *; } +-dontwarn io.flutter.** + +########################################## +## ✅ FIREBASE (Safe) +########################################## +-keep class com.google.firebase.** { *; } +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.firebase.** +-dontwarn com.google.android.gms.** + +########################################## +## ✅ FCM Background Messaging +########################################## +-keep class com.google.firebase.messaging.FirebaseMessagingService { *; } +-keep class com.google.firebase.messaging.FirebaseMessaging { *; } +-keep class com.google.firebase.messaging.RemoteMessage { *; } + +########################################## +## ✅ Gson / JSON Models (Correct way) +########################################## +-keepclassmembers class * { + @com.google.gson.annotations.SerializedName ; +} + +# Keep all model classes to prevent crashes +-keep class **.model.** { *; } +-keep class **.models.** { *; } + +########################################## +## ✅ Retrofit / OkHttp +########################################## +-dontwarn okhttp3.** +-dontwarn okio.** +-dontwarn retrofit2.** +-keep class okhttp3.** { *; } +-keep class okio.** { *; } +-keep interface retrofit2.** { *; } + +########################################## +## ✅ Glide / image loading (avoid BigPicture FCM crash) +########################################## +-dontwarn com.bumptech.glide.** +-keep class com.bumptech.glide.** { *; } + +########################################## +## ✅ Prevent crashes for reflection +########################################## +-keepattributes Signature +-keepattributes Annotation + +########################################## +## ✅ KEEP ENUMS (Firebase uses it) +########################################## +-keepclassmembers enum * { *; } + +########################################## +## ✅ Do NOT break Kotlin (Important) +########################################## +-keep class kotlin.** { *; } +-dontwarn kotlin.** + +########################################## +## ✅ GETX (State Management & Snackbars) +########################################## +# Keep all GetX classes and methods +-keep class get.** { *; } +-keep class * extends get.GetxController { *; } +-keepclassmembers class * extends get.GetxController { + ; + ; +} +# Keep GetX navigation and routing +-keep class get.Get { *; } +-keep class get.GetMaterialApp { *; } +-keep class get.GetNavigator { *; } +-keep class get.GetSnackbar { *; } +# Keep GetX observables and reactive variables +-keep class get.Rx* { *; } +-keep class get.Obs* { *; } +-dontwarn get.** + +########################################## +## ✅ Reduce unnecessary warnings +########################################## +-ignorewarnings diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..8ffe024 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..155f8ba --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/nearle/gear/MainActivity.kt b/android/app/src/main/kotlin/com/nearle/gear/MainActivity.kt new file mode 100644 index 0000000..78d4cb7 --- /dev/null +++ b/android/app/src/main/kotlin/com/nearle/gear/MainActivity.kt @@ -0,0 +1,315 @@ +package com.doormile.partner + +import android.app.AlarmManager +import android.app.PendingIntent +import android.app.PictureInPictureParams +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.content.res.Configuration +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.util.Rational +import androidx.core.view.WindowCompat +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel +import java.util.Calendar + +class MainActivity : FlutterActivity() { + private val channelName = "doormile/pip" + private val shiftEndChannelName = "doormile/shift_end" + + // PiP tracking for safe exit on resume + private val PREFS = "app_prefs" + private val KEY_WAS_IN_PIP = "was_in_pip" + private val handler = Handler(Looper.getMainLooper()) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // No automatic PiP entry here – only from explicit Flutter calls + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + // Enable edge-to-edge for Android 15+ (SDK 35+) + if (Build.VERSION.SDK_INT >= 35) { + WindowCompat.setDecorFitsSystemWindows(window, false) + } + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) + .setMethodCallHandler { call, result -> + when (call.method) { + "enterPip" -> { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val aspect = Rational(16, 9) + val params = PictureInPictureParams.Builder() + .setAspectRatio(aspect) + .build() + enterPictureInPictureMode(params) + } else { + @Suppress("DEPRECATION") + enterPictureInPictureMode() + } + result.success(true) + } catch (e: Exception) { + result.error("PIP_ERROR", e.message, null) + } + } + "moveToBack" -> { + try { + moveTaskToBack(true) + result.success(true) + } catch (e: Exception) { + result.error("MOVE_BACK_ERROR", e.message, null) + } + } + "hasActivePickups" -> { + try { + val hasActive = checkActivePickups() + result.success(hasActive) + } catch (e: Exception) { + result.error("CHECK_ACTIVE_ERROR", e.message, null) + } + } + "ensureExitPip" -> { + try { + ensureExitPip() + result.success(true) + } catch (e: Exception) { + result.error("ENSURE_EXIT_PIP_ERROR", e.message, null) + } + } + else -> result.notImplemented() + } + } + + // Shift end alarm channel + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, shiftEndChannelName) + .setMethodCallHandler { call, result -> + when (call.method) { + "scheduleShiftEndAlarm" -> { + try { + val endTimeStr = call.argument("endTime") ?: "" + val startTimeStr = call.argument("startTime") ?: "" + if (endTimeStr.isNotEmpty()) { + scheduleShiftEndAlarm(endTimeStr, startTimeStr) + result.success(true) + } else { + result.error("INVALID_TIME", "End time is required", null) + } + } catch (e: Exception) { + result.error("ALARM_ERROR", e.message, null) + } + } + "cancelShiftEndAlarm" -> { + try { + cancelShiftEndAlarm() + result.success(true) + } catch (e: Exception) { + result.error("CANCEL_ERROR", e.message, null) + } + } + "handleShiftEnd" -> { + try { + // This is called by the BroadcastReceiver + // We need to call Flutter code to create break log + // Since we're in MainActivity, we can use the existing Flutter engine + // But the receiver might not have access to it, so we'll handle it differently + // The receiver will directly call the background service method + result.success(true) + } catch (e: Exception) { + result.error("HANDLE_ERROR", e.message, null) + } + } + else -> result.notImplemented() + } + } + } + + // Let Flutter-side explicitly control when to enter PiP (navigation only) + override fun onUserLeaveHint() { + super.onUserLeaveHint() + } + + override fun onPictureInPictureModeChanged( + isInPictureInPictureMode: Boolean, + newConfig: Configuration? + ) { + super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + // Persist PiP state so we can safely exit it on resume + val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) + prefs.edit().putBoolean(KEY_WAS_IN_PIP, isInPictureInPictureMode).apply() + } + + override fun onResume() { + super.onResume() + // When user brings app back, force full-screen (no PiP) + ensureExitPip() + } + + private fun ensureExitPip() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val wasInPip = prefs.getBoolean(KEY_WAS_IN_PIP, false) + + // Clear the persisted flag so we don't auto-re-enter PiP + // Note: Android doesn't provide a direct API to exit PiP programmatically, + // but when user opens app from launcher, it naturally returns to full-screen. + // We just ensure we don't auto-re-enter PiP on resume. + if (wasInPip) { + prefs.edit().putBoolean(KEY_WAS_IN_PIP, false).apply() + } + } + + private fun checkActivePickups(): Boolean { + return try { + val prefs: SharedPreferences = getSharedPreferences( + "FlutterSharedPreferences", + Context.MODE_PRIVATE + ) + // Only check has_live_Pick up (verified by API) + val hasLive = prefs.getBoolean("flutter.has_live_Pick up", false) + hasLive + } catch (e: Exception) { + false + } + } + + private fun scheduleShiftEndAlarm(endTimeStr: String, startTimeStr: String) { + try { + Log.d("MainActivity", "📅 Scheduling shift end alarm - endTime: $endTimeStr, startTime: $startTimeStr") + + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(this, ShiftEndReceiver::class.java).apply { + action = "com.doormile.partner.SHIFT_END_ALARM" + } + val pendingIntent = PendingIntent.getBroadcast( + this, + 1001, // Unique request code + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + // Parse end time (format: HH:mm:ss or HH:mm) + val timeParts = endTimeStr.split(":") + if (timeParts.size < 2) { + Log.e("MainActivity", "❌ Invalid end time format: $endTimeStr") + return + } + + val endHour = timeParts[0].toIntOrNull() ?: 0 + val endMinute = timeParts[1].toIntOrNull() ?: 0 + + val calendar = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, endHour) + set(Calendar.MINUTE, endMinute) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + // Handle overnight shifts (if start > end) + if (startTimeStr.isNotEmpty()) { + val startParts = startTimeStr.split(":") + if (startParts.size >= 2) { + val startHour = startParts[0].toIntOrNull() ?: 0 + val startMinute = startParts[1].toIntOrNull() ?: 0 + + if (startHour > endHour || (startHour == endHour && startMinute > endMinute)) { + // Overnight shift - if current time is before end time, schedule for tomorrow + val now = Calendar.getInstance() + if (now.before(calendar)) { + calendar.add(Calendar.DAY_OF_MONTH, 1) + } + } + } + } + + // ✅ CRITICAL: If shift end time has already passed today, trigger immediately + val now = Calendar.getInstance() + Log.d("MainActivity", "Current time: ${now.time}, Shift end time: ${calendar.time}") + + if (calendar.before(now) || calendar.equals(now)) { + // Shift end time has passed - trigger break log immediately + Log.d("MainActivity", "⚡ Shift end time ($endTimeStr) has already passed - triggering break log immediately") + + // Trigger the receiver immediately + val immediateIntent = Intent(this, ShiftEndReceiver::class.java).apply { + action = "com.doormile.partner.SHIFT_END_ALARM" + } + sendBroadcast(immediateIntent) + Log.d("MainActivity", "✅ Sent immediate broadcast to ShiftEndReceiver") + + // Also schedule for tomorrow to handle next shift + calendar.add(Calendar.DAY_OF_MONTH, 1) + Log.d("MainActivity", "📅 Also scheduled shift end alarm for tomorrow: ${calendar.time}") + } + + // Schedule exact alarm (Android 12+ requires SCHEDULE_EXACT_ALARM permission) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (alarmManager.canScheduleExactAlarms()) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + Log.d("MainActivity", "✅ Scheduled exact shift end alarm for: ${calendar.time} (${calendar.timeInMillis})") + } else { + // Fallback to inexact alarm + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + Log.w("MainActivity", "⚠️ Scheduled inexact shift end alarm (no permission) for: ${calendar.time}") + } + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + Log.d("MainActivity", "✅ Scheduled exact shift end alarm (Android M+) for: ${calendar.time}") + } else { + @Suppress("DEPRECATION") + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + Log.d("MainActivity", "✅ Scheduled exact shift end alarm (legacy) for: ${calendar.time}") + } + } + } catch (e: Exception) { + Log.e("MainActivity", "❌ Error scheduling shift end alarm: ${e.message}", e) + e.printStackTrace() + } + } + + private fun cancelShiftEndAlarm() { + try { + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(this, ShiftEndReceiver::class.java).apply { + action = "com.doormile.partner.SHIFT_END_ALARM" + } + val pendingIntent = PendingIntent.getBroadcast( + this, + 1001, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + alarmManager.cancel(pendingIntent) + Log.d("MainActivity", "Cancelled shift end alarm") + } catch (e: Exception) { + Log.e("MainActivity", "Error cancelling shift end alarm: ${e.message}", e) + } + } +} + diff --git a/android/app/src/main/kotlin/com/nearle/gear/ShiftEndReceiver.kt b/android/app/src/main/kotlin/com/nearle/gear/ShiftEndReceiver.kt new file mode 100644 index 0000000..fe8a42d --- /dev/null +++ b/android/app/src/main/kotlin/com/nearle/gear/ShiftEndReceiver.kt @@ -0,0 +1,278 @@ +package com.doormile.partner + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.PowerManager +import android.util.Log +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import java.text.SimpleDateFormat +import java.util.* + +/** + * BroadcastReceiver that triggers when shift end alarm fires + * This works even when app is killed + * + * When app is killed, we directly call the API to create break log + * since we can't reliably start Flutter engine + */ +class ShiftEndReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Log.d("ShiftEndReceiver", "🔔 Shift end alarm triggered at ${java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss", java.util.Locale.US).format(java.util.Calendar.getInstance().time)}") + + // ✅ CRITICAL: Acquire WakeLock to keep device awake during API call + val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager + val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "ShiftEndReceiver::WakeLock") + wakeLock.acquire(60 * 1000L) // Hold for 60 seconds max + + // Execute API call in background thread + Thread { + try { + // Check if rider is still on duty + val prefs: SharedPreferences = context.getSharedPreferences( + "FlutterSharedPreferences", + Context.MODE_PRIVATE + ) + + // ✅ Try multiple key formats (Flutter SharedPreferences uses "flutter." prefix) + val onduty = prefs.getInt("flutter.onduty", 0) + Log.d("ShiftEndReceiver", "Checking onduty: $onduty") + + if (onduty != 1) { + Log.d("ShiftEndReceiver", "Rider already offline (onduty=$onduty), skipping break log creation") + return@Thread + } + + // Get required IDs - try both flutter.userid and flutter.userId + val userid = prefs.getInt("flutter.userid", 0).takeIf { it != 0 } + ?: prefs.getInt("flutter.userId", 0) + val partnerid = prefs.getInt("flutter.partnerid", 0).takeIf { it != 0 } + ?: prefs.getInt("flutter.partnerId", 0) + val shiftid = prefs.getInt("flutter.shiftid", 0).takeIf { it != 0 } + ?: prefs.getInt("flutter.shiftId", 0) + val logid = prefs.getInt("flutter.logid", 0).takeIf { it != 0 } + ?: prefs.getInt("flutter.logId", 0) + + Log.d("ShiftEndReceiver", "Retrieved IDs: userid=$userid, partnerid=$partnerid, shiftid=$shiftid, logid=$logid") + + if (userid == 0) { + Log.e("ShiftEndReceiver", "❌ Missing userid, cannot create break log. Available keys: ${prefs.all.keys}") + return@Thread + } + + // Get API base URL (check if live or dev) + // ✅ Match homepage logic: use createbreaklog (not createbreakriderlog) + val mainRoute = prefs.getString("flutter.mainRoute", "dev") ?: "dev" + val isLive = mainRoute == "live" + Log.d("ShiftEndReceiver", "API Route: $mainRoute (isLive=$isLive)") + val baseUrl = if (isLive) { + "https://jupiter.doormile.app/live/api/v2/partners/createbreaklog" + } else { + "https://jupiter.doormile.app/dev/api/v2/partners/createbreaklog" + } + + // Create break log payload + val now = Calendar.getInstance() + val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.US) + val breakdate = dateFormat.format(now.time) + val breakstart = timeFormat.format(now.time) + val localBreakId = (System.currentTimeMillis() % 900).toInt() + 100 + + // ✅ Compact JSON (no extra whitespace) - matches Flutter format + val payload = """{"breakid":$localBreakId,"logid":$logid,"breakdate":"$breakdate","userid":$userid,"partnerid":$partnerid,"shiftid":$shiftid,"breakstart":"$breakstart","breakend":"","breakhours":0.0,"latitude":"0","longitude":"0"}""" + + Log.d("ShiftEndReceiver", "📤 Sending break log request to: $baseUrl") + Log.d("ShiftEndReceiver", "📦 Payload: $payload") + + // Make API call + val url = URL(baseUrl) + val connection = url.openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("Accept", "application/json") + connection.doOutput = true + connection.doInput = true + connection.useCaches = false + connection.connectTimeout = 15000 // Increased timeout + connection.readTimeout = 15000 + + Log.d("ShiftEndReceiver", "🔌 Connecting to API...") + + // Write payload + val outputStream = connection.outputStream + val writer = OutputStreamWriter(outputStream, "UTF-8") + writer.write(payload) + writer.flush() + writer.close() + + Log.d("ShiftEndReceiver", "📨 Request sent, waiting for response...") + + val responseCode = connection.responseCode + Log.d("ShiftEndReceiver", "📥 Break log API response: HTTP $responseCode") + + // Read response body for debugging + try { + val responseStream = if (responseCode in 200..299) { + connection.inputStream + } else { + connection.errorStream + } + if (responseStream != null) { + val responseBody = responseStream.bufferedReader().use { it.readText() } + Log.d("ShiftEndReceiver", "📄 Response body: $responseBody") + } + } catch (e: Exception) { + Log.w("ShiftEndReceiver", "Could not read response body: ${e.message}") + } + + if (responseCode in 200..299) { + Log.d("ShiftEndReceiver", "✅ Break log created successfully (HTTP $responseCode)") + + // Set offline locally + prefs.edit() + .putInt("flutter.onduty", 0) + .putBoolean("flutter.online", false) + .apply() + + // Also update rider log to set onduty=0 + val updateUrl = if (isLive) { + "https://jupiter.doormile.app/live/api/v2/partners/updateriderlog" + } else { + "https://jupiter.doormile.app/dev/api/v2/partners/updateriderlog" + } + + // ✅ Compact JSON (no extra whitespace) + val updatePayload = """{"userid":$userid,"onduty":0,"latitude":"0","longitude":"0"}""" + + Log.d("ShiftEndReceiver", "📤 Updating rider status to offline") + + try { + val updateConnection = URL(updateUrl).openConnection() as HttpURLConnection + updateConnection.requestMethod = "POST" + updateConnection.setRequestProperty("Content-Type", "application/json") + updateConnection.doOutput = true + updateConnection.connectTimeout = 5000 + updateConnection.readTimeout = 5000 + + val updateWriter = OutputStreamWriter(updateConnection.outputStream, "UTF-8") + updateWriter.write(updatePayload) + updateWriter.flush() + updateWriter.close() + + if (updateConnection.responseCode in 200..299) { + Log.d("ShiftEndReceiver", "✅ Rider status updated to Offline") + } + updateConnection.disconnect() + } catch (e: Exception) { + Log.e("ShiftEndReceiver", "Error updating rider status: ${e.message}") + } + } else { + // Read error response for debugging + try { + val errorStream = connection.errorStream + if (errorStream != null) { + val errorResponse = errorStream.bufferedReader().use { it.readText() } + Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode\nError: $errorResponse") + } else { + Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode") + } + } catch (e: Exception) { + Log.e("ShiftEndReceiver", "❌ Failed to create break log: HTTP $responseCode (Error reading response: ${e.message})") + } + } + + connection.disconnect() + + // ✅ CRITICAL: Reschedule alarm for tomorrow (so it works every day automatically) + // This ensures the alarm fires every day at shift end time even if app is killed + try { + val endTimeStr = prefs.getString("flutter.endtime", "") ?: "" + val startTimeStr = prefs.getString("flutter.starttime", "") ?: "" + Log.d("ShiftEndReceiver", "Rescheduling alarm - endTime: $endTimeStr, startTime: $startTimeStr") + + if (endTimeStr.isNotEmpty()) { + // Schedule alarm for tomorrow at the same time + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val intent = Intent(context, ShiftEndReceiver::class.java).apply { + action = "com.doormile.partner.SHIFT_END_ALARM" + } + val pendingIntent = PendingIntent.getBroadcast( + context, + 1001, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val timeParts = endTimeStr.split(":") + if (timeParts.size >= 2) { + val endHour = timeParts[0].toIntOrNull() ?: 0 + val endMinute = timeParts[1].toIntOrNull() ?: 0 + + val calendar = Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, 1) // Tomorrow + set(Calendar.HOUR_OF_DAY, endHour) + set(Calendar.MINUTE, endMinute) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) { + if (alarmManager.canScheduleExactAlarms()) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + Log.d("ShiftEndReceiver", "✅ Rescheduled alarm for tomorrow: ${calendar.time}") + } else { + alarmManager.setAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + } + } else { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + } else { + @Suppress("DEPRECATION") + alarmManager.setExact( + AlarmManager.RTC_WAKEUP, + calendar.timeInMillis, + pendingIntent + ) + } + Log.d("ShiftEndReceiver", "✅ Rescheduled alarm for tomorrow: ${calendar.time}") + } + } + } + } catch (e: Exception) { + Log.e("ShiftEndReceiver", "Error rescheduling alarm: ${e.message}", e) + } + } catch (e: Exception) { + Log.e("ShiftEndReceiver", "❌ Error handling shift end: ${e.message}", e) + e.printStackTrace() + } finally { + // ✅ CRITICAL: Release WakeLock in finally block + try { + if (wakeLock.isHeld) { + wakeLock.release() + Log.d("ShiftEndReceiver", "🔓 WakeLock released") + } + } catch (e: Exception) { + Log.e("ShiftEndReceiver", "Error releasing WakeLock: ${e.message}") + } + } + }.start() + } +} diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png new file mode 100644 index 0000000..6289152 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000..6289152 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png new file mode 100644 index 0000000..0d0edc0 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 0000000..0d0edc0 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/android/app/src/main/res/drawable-night-hdpi/android12splash.png new file mode 100644 index 0000000..6289152 Binary files /dev/null and b/android/app/src/main/res/drawable-night-hdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/android/app/src/main/res/drawable-night-mdpi/android12splash.png new file mode 100644 index 0000000..0d0edc0 Binary files /dev/null and b/android/app/src/main/res/drawable-night-mdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png new file mode 100644 index 0000000..3511a9e Binary files /dev/null and b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png new file mode 100644 index 0000000..2af1d3f Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png new file mode 100644 index 0000000..45bc617 Binary files /dev/null and b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 0000000..8e21404 Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png new file mode 100644 index 0000000..3511a9e Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000..3511a9e Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png new file mode 100644 index 0000000..2af1d3f Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 0000000..2af1d3f Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png new file mode 100644 index 0000000..45bc617 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 0000000..45bc617 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png new file mode 100644 index 0000000..8e21404 Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..3cc4948 --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..9d5fb44 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..3a7ce16 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..41bc885 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..7f1ba86 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..7c0a9b8 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/raw/alert_1.mp3 b/android/app/src/main/res/raw/alert_1.mp3 new file mode 100644 index 0000000..f7ff7c6 Binary files /dev/null and b/android/app/src/main/res/raw/alert_1.mp3 differ diff --git a/android/app/src/main/res/raw/alert_10.mp3 b/android/app/src/main/res/raw/alert_10.mp3 new file mode 100644 index 0000000..1b3504e Binary files /dev/null and b/android/app/src/main/res/raw/alert_10.mp3 differ diff --git a/android/app/src/main/res/raw/alert_2.mp3 b/android/app/src/main/res/raw/alert_2.mp3 new file mode 100644 index 0000000..de379f9 Binary files /dev/null and b/android/app/src/main/res/raw/alert_2.mp3 differ diff --git a/android/app/src/main/res/raw/alert_3.mp3 b/android/app/src/main/res/raw/alert_3.mp3 new file mode 100644 index 0000000..5193f6d Binary files /dev/null and b/android/app/src/main/res/raw/alert_3.mp3 differ diff --git a/android/app/src/main/res/raw/alert_4.mp3 b/android/app/src/main/res/raw/alert_4.mp3 new file mode 100644 index 0000000..fab7581 Binary files /dev/null and b/android/app/src/main/res/raw/alert_4.mp3 differ diff --git a/android/app/src/main/res/raw/alert_5.mp3 b/android/app/src/main/res/raw/alert_5.mp3 new file mode 100644 index 0000000..1b3504e Binary files /dev/null and b/android/app/src/main/res/raw/alert_5.mp3 differ diff --git a/android/app/src/main/res/raw/alert_6.mp3 b/android/app/src/main/res/raw/alert_6.mp3 new file mode 100644 index 0000000..ef49e86 Binary files /dev/null and b/android/app/src/main/res/raw/alert_6.mp3 differ diff --git a/android/app/src/main/res/raw/alert_7.mp3 b/android/app/src/main/res/raw/alert_7.mp3 new file mode 100644 index 0000000..5c63693 Binary files /dev/null and b/android/app/src/main/res/raw/alert_7.mp3 differ diff --git a/android/app/src/main/res/raw/alert_8.mp3 b/android/app/src/main/res/raw/alert_8.mp3 new file mode 100644 index 0000000..ca6b745 Binary files /dev/null and b/android/app/src/main/res/raw/alert_8.mp3 differ diff --git a/android/app/src/main/res/raw/alert_9.mp3 b/android/app/src/main/res/raw/alert_9.mp3 new file mode 100644 index 0000000..3de4c9c Binary files /dev/null and b/android/app/src/main/res/raw/alert_9.mp3 differ diff --git a/android/app/src/main/res/raw/destination.mp3 b/android/app/src/main/res/raw/destination.mp3 new file mode 100644 index 0000000..874e9ce Binary files /dev/null and b/android/app/src/main/res/raw/destination.mp3 differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..1b8e45f --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..da32b80 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..8012d02 --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..09e68d1 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..8ffe024 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..1f88145 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..475a628 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..dcc7e10 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/android/key.properties b/android/key.properties new file mode 100644 index 0000000..ad921fe --- /dev/null +++ b/android/key.properties @@ -0,0 +1,4 @@ +storePassword=123456789 +keyPassword=123456789 +keyAlias= doormile +storeFile=doormilerider-keystore.jks \ No newline at end of file diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..5643f9c --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,27 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("com.google.gms.google-services") version "4.4.2" apply false +} + +include(":app") diff --git a/assets/audio/alert-1.mp3 b/assets/audio/alert-1.mp3 new file mode 100644 index 0000000..f7ff7c6 Binary files /dev/null and b/assets/audio/alert-1.mp3 differ diff --git a/assets/audio/alert-10.mp3 b/assets/audio/alert-10.mp3 new file mode 100644 index 0000000..1b3504e Binary files /dev/null and b/assets/audio/alert-10.mp3 differ diff --git a/assets/audio/alert-2.mp3 b/assets/audio/alert-2.mp3 new file mode 100644 index 0000000..de379f9 Binary files /dev/null and b/assets/audio/alert-2.mp3 differ diff --git a/assets/audio/alert-3.mp3 b/assets/audio/alert-3.mp3 new file mode 100644 index 0000000..5193f6d Binary files /dev/null and b/assets/audio/alert-3.mp3 differ diff --git a/assets/audio/alert-4.mp3 b/assets/audio/alert-4.mp3 new file mode 100644 index 0000000..fab7581 Binary files /dev/null and b/assets/audio/alert-4.mp3 differ diff --git a/assets/audio/alert-5.mp3 b/assets/audio/alert-5.mp3 new file mode 100644 index 0000000..1b3504e Binary files /dev/null and b/assets/audio/alert-5.mp3 differ diff --git a/assets/audio/alert-6.mp3 b/assets/audio/alert-6.mp3 new file mode 100644 index 0000000..ef49e86 Binary files /dev/null and b/assets/audio/alert-6.mp3 differ diff --git a/assets/audio/alert-7.mp3 b/assets/audio/alert-7.mp3 new file mode 100644 index 0000000..5c63693 Binary files /dev/null and b/assets/audio/alert-7.mp3 differ diff --git a/assets/audio/alert-8.mp3 b/assets/audio/alert-8.mp3 new file mode 100644 index 0000000..ca6b745 Binary files /dev/null and b/assets/audio/alert-8.mp3 differ diff --git a/assets/audio/alert-9.mp3 b/assets/audio/alert-9.mp3 new file mode 100644 index 0000000..3de4c9c Binary files /dev/null and b/assets/audio/alert-9.mp3 differ diff --git a/assets/audio/destination.mp3 b/assets/audio/destination.mp3 new file mode 100644 index 0000000..874e9ce Binary files /dev/null and b/assets/audio/destination.mp3 differ diff --git a/assets/fonts/Manrope/Manrope-VF.ttf b/assets/fonts/Manrope/Manrope-VF.ttf new file mode 100644 index 0000000..23dcf5e Binary files /dev/null and b/assets/fonts/Manrope/Manrope-VF.ttf differ diff --git a/assets/fonts/PlusJakartaSans/PlusJakartaSans-VF.ttf b/assets/fonts/PlusJakartaSans/PlusJakartaSans-VF.ttf new file mode 100644 index 0000000..0cb13a9 Binary files /dev/null and b/assets/fonts/PlusJakartaSans/PlusJakartaSans-VF.ttf differ diff --git a/assets/fonts/Poppins/Poppins-Black.ttf b/assets/fonts/Poppins/Poppins-Black.ttf new file mode 100644 index 0000000..6612e52 Binary files /dev/null and b/assets/fonts/Poppins/Poppins-Black.ttf differ diff --git a/assets/fonts/Poppins/Poppins-Bold.ttf b/assets/fonts/Poppins/Poppins-Bold.ttf new file mode 100644 index 0000000..1982f38 Binary files /dev/null and b/assets/fonts/Poppins/Poppins-Bold.ttf differ diff --git a/assets/fonts/Poppins/Poppins-ExtraBold.ttf b/assets/fonts/Poppins/Poppins-ExtraBold.ttf new file mode 100644 index 0000000..167667d Binary files /dev/null and b/assets/fonts/Poppins/Poppins-ExtraBold.ttf differ diff --git a/assets/fonts/Poppins/Poppins-Light.ttf b/assets/fonts/Poppins/Poppins-Light.ttf new file mode 100644 index 0000000..931ae8f Binary files /dev/null and b/assets/fonts/Poppins/Poppins-Light.ttf differ diff --git a/assets/fonts/Poppins/Poppins-Medium.ttf b/assets/fonts/Poppins/Poppins-Medium.ttf new file mode 100644 index 0000000..a590f5c Binary files /dev/null and b/assets/fonts/Poppins/Poppins-Medium.ttf differ diff --git a/assets/fonts/Poppins/Poppins-Regular.ttf b/assets/fonts/Poppins/Poppins-Regular.ttf new file mode 100644 index 0000000..0bda228 Binary files /dev/null and b/assets/fonts/Poppins/Poppins-Regular.ttf differ diff --git a/assets/fonts/Poppins/Poppins-SemiBold.ttf b/assets/fonts/Poppins/Poppins-SemiBold.ttf new file mode 100644 index 0000000..c30ad10 Binary files /dev/null and b/assets/fonts/Poppins/Poppins-SemiBold.ttf differ diff --git a/assets/fonts/ProximaNova/proximanova_bold.otf b/assets/fonts/ProximaNova/proximanova_bold.otf new file mode 100644 index 0000000..1ea7753 Binary files /dev/null and b/assets/fonts/ProximaNova/proximanova_bold.otf differ diff --git a/assets/fonts/ProximaNova/proximanova_regular.ttf b/assets/fonts/ProximaNova/proximanova_regular.ttf new file mode 100644 index 0000000..7d00247 Binary files /dev/null and b/assets/fonts/ProximaNova/proximanova_regular.ttf differ diff --git a/assets/images/ChatGPT Image Aug 7, 2026, 01_01_54 PM.png b/assets/images/ChatGPT Image Aug 7, 2026, 01_01_54 PM.png new file mode 100644 index 0000000..42d2baa Binary files /dev/null and b/assets/images/ChatGPT Image Aug 7, 2026, 01_01_54 PM.png differ 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 new file mode 100644 index 0000000..f534d45 Binary files /dev/null and b/assets/images/ChatGPT Image Aug 7, 2026, 01_02_12 PM.png differ diff --git a/assets/images/_icon_preview.png b/assets/images/_icon_preview.png new file mode 100644 index 0000000..ac86c5a Binary files /dev/null and b/assets/images/_icon_preview.png differ diff --git a/assets/images/customermap.png b/assets/images/customermap.png new file mode 100644 index 0000000..72fc07c Binary files /dev/null and b/assets/images/customermap.png differ diff --git a/assets/images/deliveryicon.png b/assets/images/deliveryicon.png new file mode 100644 index 0000000..750dff5 Binary files /dev/null and b/assets/images/deliveryicon.png differ diff --git a/assets/images/doormile-logo.png b/assets/images/doormile-logo.png new file mode 100644 index 0000000..8f17e40 Binary files /dev/null and b/assets/images/doormile-logo.png differ diff --git a/assets/images/doormile_icon.png b/assets/images/doormile_icon.png new file mode 100644 index 0000000..f643518 Binary files /dev/null and b/assets/images/doormile_icon.png differ diff --git a/assets/images/preloader.png b/assets/images/preloader.png new file mode 100644 index 0000000..92cb60e Binary files /dev/null and b/assets/images/preloader.png differ diff --git a/assets/images/week.png b/assets/images/week.png new file mode 100644 index 0000000..9c5cccb Binary files /dev/null and b/assets/images/week.png differ diff --git a/assets/lotties/Error Occurred!.json b/assets/lotties/Error Occurred!.json new file mode 100644 index 0000000..9ae1360 --- /dev/null +++ b/assets/lotties/Error Occurred!.json @@ -0,0 +1 @@ +{"v":"5.7.4","fr":30,"ip":0,"op":162,"w":72,"h":72,"nm":"Comp 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 2","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":135,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":35,"s":[45.812,23.438,0],"to":[0.458,0.417,0],"ti":[-0.458,-0.417,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":47,"s":[48.562,25.938,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":120,"s":[48.562,25.938,0],"to":[-0.458,-0.417,0],"ti":[0.458,0.417,0]},{"t":131,"s":[45.812,23.438,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-5.938,1.562,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":3,"s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":35,"s":[34.224,5.708]},{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":47,"s":[5.224,5.708]},{"i":{"x":[0.833,0.833],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":120,"s":[5.224,5.708]},{"t":131,"s":[34.224,5.708]}],"ix":2},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":35,"s":[0,0],"to":[-2.333,0.333],"ti":[2.333,-0.333]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":47,"s":[-14,2],"to":[0,0],"ti":[0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":120,"s":[-14,2],"to":[2.333,-0.333],"ti":[-2.333,0.333]},{"t":131,"s":[0,0]}],"ix":3},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":35,"s":[32]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":47,"s":[32]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":120,"s":[32]},{"t":131,"s":[32]}],"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-5.969,1.696],"ix":2},"a":{"a":0,"k":[-17.143,-0.033],"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":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":480,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 1","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":45,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":35,"s":[21.812,23.188,0],"to":[0.083,0,0],"ti":[-0.083,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":47,"s":[22.312,23.188,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":120,"s":[22.312,23.188,0],"to":[-0.083,0,0],"ti":[0.083,0,0]},{"t":131,"s":[21.812,23.188,0]}],"ix":2,"l":2},"a":{"a":0,"k":[-5.938,1.562,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":3,"s":{"a":1,"k":[{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":35,"s":[34.224,5.708]},{"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":47,"s":[5.224,5.708]},{"i":{"x":[0.833,0.833],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":120,"s":[5.224,5.708]},{"t":131,"s":[34.224,5.708]}],"ix":2},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":35,"s":[0,0],"to":[-2.333,0.333],"ti":[2.333,-0.333]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":47,"s":[-14,2],"to":[0,0],"ti":[0,0]},{"i":{"x":0.833,"y":1},"o":{"x":0.167,"y":0},"t":120,"s":[-14,2],"to":[2.333,-0.333],"ti":[-2.333,0.333]},{"t":131,"s":[0,0]}],"ix":3},"r":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":35,"s":[32]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":47,"s":[32]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":120,"s":[32]},{"t":131,"s":[32]}],"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-5.969,1.446],"ix":2},"a":{"a":0,"k":[-17.143,-0.033],"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":"Transform"}],"nm":"Rectangle 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":480,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"mouth/Payment failed Outlines","parent":4,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":44,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[100]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":120,"s":[100]},{"t":123,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[34.5,39.5,0],"ix":2,"l":2},"a":{"a":0,"k":[13.5,5,0],"ix":1,"l":2},"s":{"a":0,"k":[100,100,100],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-0.753,0.444],[0.51,0.717],[1.924,1.132],[2.749,0.012],[2.391,-1.379],[1.31,-1.81],[-0.749,-0.45],[-0.533,0.699],[-1.454,0.839],[-2.2,-0.009],[-1.904,-1.119],[-1.007,-1.344]],"o":[[0.753,-0.444],[-1.295,-1.821],[-2.379,-1.399],[-2.749,-0.012],[-1.933,1.114],[-0.516,0.712],[0.749,0.451],[1.018,-1.335],[1.913,-1.103],[2.199,0.01],[1.447,0.852],[0.527,0.704]],"v":[[12.23,4.176],[12.736,2.029],[7.857,-2.454],[0.031,-4.607],[-7.814,-2.521],[-12.729,1.919],[-12.242,4.07],[-10.001,3.562],[-6.258,0.266],[0.018,-1.404],[6.279,0.32],[9.993,3.648]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[13.496,4.869],"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":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":44,"op":123,"st":44,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Circle/Payment failed Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[36,36,0],"ix":2,"l":2},"a":{"a":0,"k":[34.5,34.5,0],"ix":1,"l":2},"s":{"a":1,"k":[{"i":{"x":[0.571,0.571,0.571],"y":[1,1,1]},"o":{"x":[0.192,0.192,0.192],"y":[0,0,0]},"t":47,"s":[100,100,100]},{"i":{"x":[0.677,0.677,0.677],"y":[1,1,1]},"o":{"x":[0.324,0.324,0.324],"y":[0,0,0]},"t":83,"s":[86,86,100]},{"t":120,"s":[100,100,100]}],"ix":6,"l":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-18.778,0],[0,-18.778],[18.778,0],[0,18.778]],"o":[[18.778,0],[0,18.778],[-18.778,0],[0,-18.778]],"v":[[0,-34],[34,0],[0,34],[-34,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.921568687289,0.341176470588,0.341176470588,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[34.25,34.25],"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":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":480,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lotties/all_caught_up.json b/assets/lotties/all_caught_up.json new file mode 100644 index 0000000..0c15138 --- /dev/null +++ b/assets/lotties/all_caught_up.json @@ -0,0 +1 @@ +{"v":"5.7.1","fr":30,"ip":0,"op":60,"w":300,"h":300,"nm":"Comp 1","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[151,151,0],"ix":2},"a":{"a":0,"k":[0,0,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],[-22.5,-7.5],[0,0]],"o":[[0,0],[22.5,7.5],[0,0]],"v":[[61,78.5],[78,100.5],[94,115.5]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.12941176470588237,0.19607843137254902,0.3843137254901961,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":28.254,"s":[0]},{"t":51,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":17,"s":[0]},{"t":46.2109375,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":17,"op":617,"st":17,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":203,"ix":10},"p":{"a":0,"k":[251.408,59.449,0],"ix":2},"a":{"a":0,"k":[-103.936,89.696,0],"ix":1},"s":{"a":0,"k":[91.456,89.381,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[29.459,25.084],[5,-25.75],[5.75,-0.75]],"o":[[0,0],[-25.25,-21.5],[-5.515,28.405],[-5.75,0.75]],"v":[[-75,68.75],[-107.25,86.5],[-94,84],[-128.75,113]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.713725490196,0.211764720842,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":28.254,"s":[0]},{"t":51,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":17,"s":[0]},{"t":46.2109375,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":17,"op":617,"st":17,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"2","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-21,347,0],"ix":2},"a":{"a":0,"k":[0,0,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],[20.5,4.5],[0,0],[18.25,9.25],[3.5,-3.25]],"o":[[0,0],[-20.5,-4.5],[0,0],[-18.25,-9.25],[-3.5,3.25]],"v":[[90.75,-122.25],[83.5,-110.5],[81.25,-97.25],[69.75,-93.25],[61,-75.75]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[0.745098039216,0.643137254902,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":28.254,"s":[0]},{"t":51,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":17,"s":[0]},{"t":46.2109375,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":17,"op":617,"st":17,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[147,146,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[109.756,101.856,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[33.75,1]],"o":[[0,0],[-33.75,-1]],"v":[[-57.75,-75.75],[-90.75,-107.5]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,0.431372578939,0.513725490196,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":5,"ix":5},"lc":2,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[-1.5,-0.25],"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":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":28.254,"s":[0]},{"t":51,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":17,"s":[0]},{"t":46.2109375,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":17,"op":617,"st":17,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"checkmark/success Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[150.06,150.061,0],"ix":2},"a":{"a":0,"k":[99,99,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":12,"s":[27,27,100]},{"t":21,"s":[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.831,1.409],[0,0],[-0.622,2.004],[-1.844,1.043],[-2.083,-0.474],[-1.215,-1.74],[0,0],[-0.949,0.537],[0,0],[-2.041,-0.632],[-1.075,-1.826],[0.463,-2.047],[1.756,-1.181]],"o":[[-1.424,0.805],[0,0],[-0.93,-1.899],[0.622,-2.006],[1.844,-1.042],[2.084,0.474],[0,0],[0.553,0.939],[0,0],[1.921,-0.899],[2.042,0.631],[1.076,1.823],[-0.463,2.047],[0,0]],"v":[[-26.138,30.587],[-30.199,29.498],[-58.899,-19.189],[-59.378,-25.261],[-55.542,-30.001],[-49.433,-30.884],[-44.302,-27.441],[-23.618,7.647],[-20.911,8.371],[47.571,-30.344],[53.736,-30.76],[58.584,-26.94],[59.537,-20.918],[56.086,-15.898]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[99.943,103.024],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[83.631,83.631],"ix":3},"r":{"a":0,"k":-12.6,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","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":[[-47.479,-12.722],[12.721,-47.478],[47.478,12.722],[-12.723,47.478]],"o":[[47.479,12.722],[-12.723,47.479],[-47.478,-12.723],[12.721,-47.479]],"v":[[23.036,-85.967],[85.969,23.035],[-23.035,85.967],[-85.966,-23.035]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0.901960790157,0.901960790157,0.901960790157,1]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[0.901960790157,0.901960790157,0.901960790157,1]},{"t":29,"s":[0.262745112181,0.823529422283,0.588235318661,1]}],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[98.939,98.939],"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":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":600,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"large-circles/success Outlines","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":42,"s":[100]},{"t":52,"s":[2]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[148,146,0],"ix":2},"a":{"a":0,"k":[95.5,98,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.009,0.009,0.333],"y":[1.621,1.621,0]},"t":17,"s":[62,62,100]},{"t":50,"s":[124,124,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-6.209,1.338],[-1.337,-6.209],[6.209,-1.337],[1.338,6.209]],"o":[[6.208,-1.337],[1.338,6.209],[-6.209,1.338],[-1.337,-6.209]],"v":[[-2.421,-11.243],[11.242,-2.422],[2.421,11.241],[-11.243,2.421]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.745000023935,0.642999985639,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[173.998,145.423],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[71,71],"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":"Transform"}],"nm":"Group 1","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":[[-6.209,1.337],[-1.338,-6.209],[6.209,-1.338],[1.338,6.209]],"o":[[6.209,-1.338],[1.337,6.209],[-6.209,1.337],[-1.337,-6.208]],"v":[[-2.421,-11.242],[11.242,-2.422],[2.422,11.242],[-11.243,2.422]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764705882352941,0.45098039215686275,0.8901960784313725,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[63.997,183.011],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[70,70],"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":"Transform"}],"nm":"Group 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":[[-6.209,1.337],[-1.337,-6.208],[6.209,-1.337],[1.338,6.209]],"o":[[6.209,-1.337],[1.338,6.209],[-6.209,1.338],[-1.337,-6.209]],"v":[[-2.421,-11.243],[11.242,-2.423],[2.421,11.242],[-11.243,2.421]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.713999968884,0.211999990426,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[103.291,12.83],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[70,70],"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":"Transform"}],"nm":"Group 3","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":[[-6.208,1.337],[-1.337,-6.209],[6.209,-1.338],[1.338,6.209]],"o":[[6.209,-1.337],[1.338,6.209],[-6.209,1.337],[-1.337,-6.209]],"v":[[-2.423,-11.242],[11.241,-2.422],[2.421,11.242],[-11.243,2.422]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.011764705882352941,0.45098039215686275,0.8901960784313725,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[178.004,59.795],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[67,67],"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":"Transform"}],"nm":"Group 4","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":[[-6.209,1.337],[-1.338,-6.208],[6.209,-1.337],[1.338,6.209]],"o":[[6.209,-1.337],[1.337,6.209],[-6.209,1.338],[-1.337,-6.209]],"v":[[-2.421,-11.243],[11.243,-2.423],[2.422,11.242],[-11.243,2.421]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,0.430999995213,0.513999968884,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[12.829,84.352],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[68,68],"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":"Transform"}],"nm":"Group 5","np":2,"cix":2,"bm":0,"ix":5,"mn":"ADBE Vector Group","hd":false}],"ip":17,"op":617,"st":17,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"checkmark/success Outlines","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[150.073,150.073,0],"ix":2},"a":{"a":0,"k":[99,99,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.203,0.203,0.667],"y":[0.507,0.507,1]},"o":{"x":[0.915,0.915,0.333],"y":[0.651,0.651,0]},"t":50,"s":[93.357,93.357,100]},{"t":59,"s":[121.357,121.357,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0.831,1.409],[0,0],[-0.622,2.004],[-1.844,1.043],[-2.083,-0.474],[-1.215,-1.74],[0,0],[-0.949,0.537],[0,0],[-2.041,-0.632],[-1.075,-1.826],[0.463,-2.047],[1.756,-1.181]],"o":[[-1.424,0.805],[0,0],[-0.93,-1.899],[0.622,-2.006],[1.844,-1.042],[2.084,0.474],[0,0],[0.553,0.939],[0,0],[1.921,-0.899],[2.042,0.631],[1.076,1.823],[-0.463,2.047],[0,0]],"v":[[-26.138,30.587],[-30.199,29.498],[-58.899,-19.189],[-59.378,-25.261],[-55.542,-30.001],[-49.433,-30.884],[-44.302,-27.441],[-23.618,7.647],[-20.911,8.371],[47.571,-30.344],[53.736,-30.76],[58.584,-26.94],[59.537,-20.918],[56.086,-15.898]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[98.943,102.024],"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":"Transform"}],"nm":"Group 1","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":[[-47.479,-12.722],[12.721,-47.478],[47.478,12.722],[-12.723,47.478]],"o":[[47.479,12.722],[-12.723,47.479],[-47.478,-12.723],[12.721,-47.479]],"v":[[23.036,-85.967],[85.969,23.035],[-23.035,85.967],[-85.966,-23.035]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.2235294117647059,0.7843137254901961,0.32941176470588235,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[98.939,98.939],"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":"Transform"}],"nm":"Group 2","np":2,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":50,"op":650,"st":50,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lotties/delivery done.json b/assets/lotties/delivery done.json new file mode 100644 index 0000000..2879fdd --- /dev/null +++ b/assets/lotties/delivery done.json @@ -0,0 +1 @@ +{"v":"5.5.7","meta":{"g":"LottieFiles AE 0.1.20","a":"Inktwin","k":"Successful animation, check, success, congrats, cangratulation","d":"Successful animation","tc":"dark"},"fr":24,"ip":0,"op":48,"w":200,"h":200,"nm":"Success","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 4","parent":4,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":60,"s":[100]},{"t":70,"s":[0]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-5.051,60.949,0],"ix":2},"a":{"a":0,"k":[77,2,0],"ix":1},"s":{"a":0,"k":[55.682,55.682,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0]],"o":[[0,0]],"v":[[955.194,251.637]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":64,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"Shape 2","np":3,"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":[[-236,0],[0,169.697],[223.622,0],[0,0],[0,0],[0,0],[0,0]],"o":[[232.172,0],[0,-178.341],[-228.293,0],[0,0],[0,0],[0,0],[0,0]],"v":[[54.299,326.269],[430.045,-2.319],[81.56,-379.819],[-238.557,-171.999],[-56,6],[30,92],[210,-88]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":64,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.851],"y":[0]},"t":48,"s":[83]},{"t":69,"s":[0]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.851],"y":[0]},"t":49,"s":[100]},{"t":70,"s":[0]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":3,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":48,"op":973,"st":13,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 2","parent":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-5.051,60.949,0],"ix":2},"a":{"a":0,"k":[77,2,0],"ix":1},"s":{"a":0,"k":[55.682,55.682,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]],"o":[[0,0],[0,0],[0,0]],"v":[[-56,6],[30,92],[210,-88]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":64,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4,0.1451,0.5098,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":0,"k":0,"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":12,"s":[0]},{"t":24,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false}],"ip":0,"op":49,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 3","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[101.49999999999999,92.5,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[25,25,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0,0],[0,0]],"o":[[0,0],[0,0]],"v":[[-8,-78],[-8,-254]],"c":false},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":20,"ix":5},"lc":2,"lj":2,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4,0.1451,0.5098,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"tm","s":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":11,"s":[0]},{"t":23,"s":[100]}],"ix":1},"e":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":9,"s":[0]},{"t":21,"s":[100]}],"ix":2},"o":{"a":0,"k":0,"ix":3},"m":1,"ix":2,"nm":"Trim Paths 1","mn":"ADBE Vector Filter - Trim","hd":false},{"ty":"rp","c":{"a":0,"k":10,"ix":1},"o":{"a":0,"k":0,"ix":2},"m":1,"ix":3,"tr":{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[-9,17],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":36,"ix":4},"so":{"a":0,"k":100,"ix":5},"eo":{"a":0,"k":100,"ix":6},"nm":"Transform"},"nm":"Repeater 1","mn":"ADBE Vector Filter - Repeater","hd":false}],"ip":-3,"op":957,"st":-3,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":56,"s":[100]},{"t":64,"s":[0]}],"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.333],"y":[0]},"t":0,"s":[45]},{"t":12,"s":[0]}],"ix":10,"x":"var $bm_rt;\nvar amp, freq, decay, n, n, t, t, v;\namp = 0.06;\nfreq = 2;\ndecay = 4;\n$bm_rt = n = 0;\nif (numKeys > 0) {\n $bm_rt = n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n}\nif (n == 0) {\n $bm_rt = t = 0;\n} else {\n $bm_rt = t = $bm_sub(time, key(n).time);\n}\nif (n > 0) {\n v = velocityAtTime($bm_sub(key(n).time, $bm_div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_sum(value, $bm_div($bm_mul($bm_mul(v, amp), Math.sin($bm_mul($bm_mul($bm_mul(freq, t), 2), Math.PI))), Math.exp($bm_mul(decay, t))));\n} else {\n $bm_rt = value;\n}"},"p":{"a":0,"k":[100,100,0],"ix":2},"a":{"a":0,"k":[-5.051,60.949,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,-15.667]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[0,0,100]},{"t":12,"s":[25,25,100]}],"ix":6,"x":"var $bm_rt;\nvar amp, freq, decay, n, n, t, t, v;\namp = 0.06;\nfreq = 2;\ndecay = 4;\n$bm_rt = n = 0;\nif (numKeys > 0) {\n $bm_rt = n = nearestKey(time).index;\n if (key(n).time > time) {\n n--;\n }\n}\nif (n == 0) {\n $bm_rt = t = 0;\n} else {\n $bm_rt = t = $bm_sub(time, key(n).time);\n}\nif (n > 0) {\n v = velocityAtTime($bm_sub(key(n).time, $bm_div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_sum(value, $bm_div($bm_mul($bm_mul(v, amp), Math.sin($bm_mul($bm_mul($bm_mul(freq, t), 2), Math.PI))), Math.exp($bm_mul(decay, t))));\n} else {\n $bm_rt = value;\n}"}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"st","c":{"a":0,"k":[0.4,0.1451,0.5098,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":8,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4,0.1451,0.5098,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false},{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[393.898,393.898],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.4,0.1451,0.5098,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":8,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.4,0.1451,0.5098,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-5.051,44.949],"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":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":2,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":960,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/assets/lotties/no_bookings.json b/assets/lotties/no_bookings.json new file mode 100644 index 0000000..330bdaf --- /dev/null +++ b/assets/lotties/no_bookings.json @@ -0,0 +1 @@ +{"v":"5.5.9","fr":60,"ip":0,"op":295,"w":426,"h":290,"nm":"search_empty","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"mouth","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":44,"s":[211,145,0],"to":[0,0.667,0],"ti":[0,-0.667,0]},{"t":69,"s":[211,149,0]}],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":44,"s":[100,100,100]},{"t":68,"s":[105,105,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[0.5,0.25],[-1.715,0.372]],"o":[[-13.025,-6.512],[7.5,-1.625]],"v":[[-10,12.25],[-31.75,12.625]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.454901960784,0.513725490196,0.580392156863,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"Shape 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"汗","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":51,"s":[0]},{"t":95,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":51,"s":[248,95.252,0],"to":[0,1.5,0],"ti":[0,-1.5,0]},{"t":94,"s":[248,104.252,0]}],"ix":2},"a":{"a":0,"k":[0,-19.333,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":51,"s":[40,40,100]},{"t":95,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-9.94,0],[0,7.27],[0,0],[0,-7.27]],"o":[[9.94,0],[0,-7.27],[0,0],[0,7.27]],"v":[[0,19],[18,5.84],[0,-19],[-18,5.84]],"c":true},"ix":2},"nm":"Path 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.69,0.976,1,0.5,0.714,0.954,0.997,1,0.738,0.932,0.994],"ix":9}},"s":{"a":0,"k":[0.019,-9.236],"ix":5},"e":{"a":0,"k":[10.208,19],"ix":6},"t":1,"nm":"Gradient Fill 1","mn":"ADBE Vector Graphic - G-Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"汗","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":569,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"eye","parent":1,"sr":1,"ks":{"o":{"a":0,"k":92,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-7.064,-11.347,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":0,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":3,"s":[100,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":6,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":9,"s":[100,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":12,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":50,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":53,"s":[100,20,100]},{"t":56,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[8.466,16.135],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"eye","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":569,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"eye","parent":1,"sr":1,"ks":{"o":{"a":0,"k":92,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[-33.767,-11.347,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":0,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":3,"s":[100,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":6,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":9,"s":[100,20,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":12,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":50,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":53,"s":[100,20,100]},{"t":56,"s":[100,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[8.466,16.135],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0,0,0,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"eye","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":569,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":0,"nm":"magnifier","refId":"comp_1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[213,145,0],"ix":2},"a":{"a":0,"k":[213,145,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":426,"h":290,"ip":0,"op":569,"st":0,"bm":0}]},{"id":"comp_1","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[213,145,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"st","c":{"a":0,"k":[0.435294117647,0.498039215686,0.564705882353,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"Shape 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"橢圓形","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-5,"ix":10},"p":{"a":0,"k":[190.106,142.222,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[99.994,100.091],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[1,1,1,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":-40,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"橢圓形","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"橢圓形","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-5,"ix":10},"p":{"a":0,"k":[192.013,142.223,0],"ix":2},"a":{"a":0,"k":[0,0,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":[[-26.81,22.62],[22.49,26.96],[26.8,-22.62],[-22.5,-26.95]],"o":[[26.81,-22.62],[-22.49,-26.96],[-26.81,22.62],[22.49,26.96]],"v":[[40.727,48.812],[48.537,-40.958],[-40.723,-48.808],[-48.533,40.952]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"fl","c":{"a":0,"k":[0.984313725490196,0.7333333333333333,0.23529411764705882,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"橢圓形","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"矩形","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-5,"ix":10},"p":{"a":0,"k":[268.024,226.941,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[42.094,74.507],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":10,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.9725490196078431,0.6235294117647059,0.10196078431372549,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":-40,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"矩形","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"矩形","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":-5,"ix":10},"p":{"a":0,"k":[238.933,197.25,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[24.446,44.702],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"fl","c":{"a":0,"k":[0.847000002861,0.847000002861,0.847000002861,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":-40,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"矩形","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0}]},{"id":"comp_2","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"合併形狀","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":60,"s":[0]},{"t":265,"s":[360]}],"ix":10},"p":{"a":0,"k":[208,27,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,2],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"rc","d":1,"s":{"a":0,"k":[2,18],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 2","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"mm","mm":2,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.796078026295,0.835294008255,0.843137025833,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"合併形狀","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"合併形狀","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[0]},{"t":358,"s":[360]}],"ix":10},"p":{"a":0,"k":[418,107,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,2],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"rc","d":1,"s":{"a":0,"k":[2,18],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 2","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"mm","mm":2,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.796078026295,0.835294008255,0.843137025833,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"合併形狀","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"合併形狀","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[0]},{"t":281,"s":[360]}],"ix":10},"p":{"a":0,"k":[31,196,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[16,2],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 1","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"rc","d":1,"s":{"a":0,"k":[2,18],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"nm":"Rectangle Path 2","mn":"ADBE Vector Shape - Rect","hd":false},{"ty":"mm","mm":2,"nm":"Merge Paths 1","mn":"ADBE Vector Filter - Merge","hd":false},{"ty":"fl","c":{"a":0,"k":[0.796078026295,0.835294008255,0.843137025833,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"合併形狀","np":4,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"橢圓形","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":281,"s":[360]}],"ix":10},"p":{"a":0,"k":[70,33,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[24,24],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.701960980892,0.756862998009,0.776471018791,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"橢圓形","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0}]},{"id":"comp_3","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"橢圓形","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[306,9,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[0.701960980892,0.756862998009,0.776471018791,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":2,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"橢圓形","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"橢圓形","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[376,179,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.701960980892,0.768626987934,0.776471018791,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"橢圓形","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"橢圓形","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[8,115,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[16,16],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.701960980892,0.768626987934,0.776471018791,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[0,0],"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":"Transform"}],"nm":"橢圓形","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":3600,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"shadow","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[191,145,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":0,"s":[70,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":30,"s":[50,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":60,"s":[70,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":90,"s":[50,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":120,"s":[70,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":150,"s":[50,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":180,"s":[70,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":210,"s":[50,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":240,"s":[70,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":270,"s":[50,100,100]},{"t":295,"s":[70,100,100]}],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[56.697,6.631],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"fl","c":{"a":0,"k":[0.848757874732,0.861812935623,0.876608455882,1],"ix":4},"o":{"a":0,"k":100,"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-9.651,124.815],"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":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":300,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":0,"nm":"main","refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[213,145,0],"to":[0,0.833,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":30,"s":[213,150,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":60,"s":[213,145,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":90,"s":[213,150,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":120,"s":[213,145,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":150,"s":[213,150,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":180,"s":[213,145,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":210,"s":[213,150,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":240,"s":[213,145,0],"to":[0,0,0],"ti":[0,-0.833,0]},{"t":270,"s":[213,150,0]}],"ix":2},"a":{"a":0,"k":[213,145,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"w":426,"h":290,"ip":0,"op":3600,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":0,"nm":"decoration1","refId":"comp_2","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.981],"y":[1.128]},"o":{"x":[0.39],"y":[0.019]},"t":8,"s":[0]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":40,"s":[100]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":72,"s":[80]},{"t":73,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[213,289,0],"ix":2},"a":{"a":0,"k":[213,289,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.667],"y":[0.866,0.866,1]},"o":{"x":[0.37,0.37,0.333],"y":[-0.029,-0.029,0]},"t":0,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":40,"s":[100,100,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":72,"s":[80,80,100]},{"t":133,"s":[90,90,100]}],"ix":6}},"ao":0,"w":426,"h":290,"ip":0,"op":569,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":0,"nm":"decoratio2","refId":"comp_3","sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":41,"s":[100]},{"t":72,"s":[100]}],"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[211,291,0],"ix":2},"a":{"a":0,"k":[211,291,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":0,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":43,"s":[99.442,99.442,100]},{"i":{"x":[0.833,0.833,0.833],"y":[0.833,0.833,0.833]},"o":{"x":[0.167,0.167,0.167],"y":[0.167,0.167,0.167]},"t":72,"s":[84.442,84.442,100]},{"t":133,"s":[90,90,100]}],"ix":6}},"ao":0,"w":426,"h":290,"ip":0,"op":569,"st":0,"bm":0}],"markers":[{"tm":180,"cm":"1","dr":0},{"tm":3600,"cm":"2","dr":0}]} \ No newline at end of file diff --git a/assets/lotties/no_distance.json b/assets/lotties/no_distance.json new file mode 100644 index 0000000..673452f --- /dev/null +++ b/assets/lotties/no_distance.json @@ -0,0 +1 @@ +{"v":"5.5.9","fr":25,"ip":0,"op":251,"w":500,"h":500,"nm":"location layer","ddd":0,"assets":[{"id":"comp_0","layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Layer 12 Outlines 4","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":375,"s":[-233.69,259.035,0],"to":[0,0,0],"ti":[0,0,0]},{"t":625,"s":[737.31,259.035,0]}]},"a":{"a":0,"k":[113.024,66.425,0]},"s":{"a":0,"k":[200,200,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-0.827,-0.827],[0,0],[-1.045,1.045],[0,0],[-1.506,0],[0,0],[-0.222,0.222],[0,0],[1.437,0],[0,0],[0.222,-0.223],[0,0],[1.516,0],[0,0],[0,0],[0.219,-0.206],[0,0]],"o":[[0,0],[1.045,1.045],[0,0],[1.065,-1.065],[0,0],[0.314,0],[0,0],[1.016,-1.017],[0,0],[-0.314,0],[0,0],[-1.073,1.072],[0,0],[0,0],[-0.3,0],[0,0],[-0.854,0.799]],"v":[[-7.407,2.045],[-6.919,2.533],[-3.136,2.533],[-2.453,1.852],[1.562,0.188],[5.715,0.188],[6.551,-0.158],[7.218,-0.825],[6.077,-3.579],[4.024,-3.579],[3.187,-3.231],[2.946,-2.99],[-1.097,-1.316],[-3.038,-1.316],[-6.474,-1.316],[-7.282,-0.997],[-7.357,-0.926]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[74.11,5.597]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-1.315,-1.315],[0,0],[0,-0.681],[0,0],[-0.742,-0.743],[0,0],[-1.139,1.14],[0,0],[-1.39,0],[0,0],[-0.925,0.925],[0,0],[1.614,1.614],[0.973,0],[0,0],[1.204,-1.204],[0,0],[0.992,0],[0,0],[0.667,-0.684],[0,0]],"o":[[0,0],[0.482,0.482],[0,0],[0,1.05],[0,0],[1.14,1.14],[0,0],[0.982,-0.982],[0,0],[1.308,0],[0,0],[1.614,-1.615],[-0.688,-0.688],[0,0],[-1.703,0],[0,0],[-0.701,0.701],[0,0],[-0.956,0],[0,0],[-1.297,1.332]],"v":[[-13.515,0.856],[-11.507,2.865],[-10.755,4.68],[-10.755,5.354],[-9.595,8.154],[-9.108,8.641],[-4.981,8.641],[-3.177,6.837],[0.528,5.303],[3.022,5.303],[6.509,3.858],[13.23,-2.862],[13.23,-8.707],[10.636,-9.781],[1.499,-9.781],[-3.042,-7.901],[-4.044,-6.898],[-6.688,-5.803],[-10.216,-5.803],[-12.754,-4.733],[-13.547,-3.919]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[94.717,10.032]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-0.461,0.461],[0,0],[-0.309,0],[0,0],[-0.219,-0.219],[0,0],[0,-0.309],[0,0],[0.218,-0.219],[0,0],[0,-0.309],[0,0],[-0.219,-0.218],[0,0],[0,1.037],[0,0],[0.218,0.218],[0,0],[0,0.309],[0,0],[1.34,1.339],[0,0],[0,0.309],[0.734,-0.733],[0,0],[0.454,0.454],[0,0],[0,0.308],[0,0],[-0.218,0.218],[0,0],[-0.309,0],[0,0],[-0.218,-0.219],[0,0],[0,1.038],[0,0],[-0.218,0.218],[0,0],[-2.16,0],[0,0],[-0.759,0.758],[0,0],[1.422,1.422],[0,0],[1.059,0],[0,0],[0.745,-0.744],[0,0],[0.309,0],[0,0],[0.218,0.219],[0,0],[-0.455,0.455],[0,0],[-0.308,0],[0,0],[-0.218,0.219],[0,0],[0.454,0.455],[0,0],[0.455,-0.455],[0,0],[0.31,0],[0,0],[0.219,-0.218],[0,0],[0.309,0],[0,0],[0.218,0.218],[0,0],[0.309,0],[0,0],[0,0],[0.218,-0.218],[0,0],[0.309,0],[0,0],[0.219,-0.219],[0,0],[-0.438,-0.457],[0,0]],"o":[[0,0],[0.219,-0.219],[0,0],[0.309,0],[0,0],[0.218,0.218],[0,0],[0,0.308],[0,0],[-0.219,0.218],[0,0],[0,0.309],[0,0],[0.733,0.734],[0,0],[0,-0.309],[0,0],[-0.218,-0.218],[0,0],[0,-1.894],[0,0],[-0.218,-0.219],[0,-1.037],[0,0],[-0.454,0.454],[0,0],[-0.218,-0.218],[0,0],[0,-0.309],[0,0],[0.218,-0.219],[0,0],[0.308,0],[0,0],[0.734,0.733],[0,0],[0,-0.309],[0,0],[1.527,-1.526],[0,0],[1.073,0],[0,0],[1.422,-1.422],[0,0],[-0.749,-0.749],[0,0],[-1.053,0],[0,0],[-0.218,0.219],[0,0],[-0.309,0],[0,0],[-0.455,-0.455],[0,0],[0.219,-0.218],[0,0],[0.309,0],[0,0],[0.454,-0.455],[0,0],[-0.455,-0.455],[0,0],[-0.219,0.218],[0,0],[-0.309,0],[0,0],[-0.219,0.218],[0,0],[-0.309,0],[0,0],[-0.218,-0.218],[0,0],[0,0],[-0.309,0],[0,0],[-0.219,0.218],[0,0],[-0.309,0],[0,0],[-0.448,0.448],[0,0],[0.452,0.471]],"v":[[-51.841,-19.552],[-49.564,-21.828],[-48.74,-22.169],[-36.561,-22.169],[-35.737,-21.828],[-33.306,-19.397],[-32.966,-18.574],[-32.966,-12.62],[-33.306,-11.797],[-40.493,-4.611],[-40.835,-3.788],[-40.835,2.079],[-40.493,2.902],[-10.697,32.698],[-8.71,31.875],[-8.71,31.061],[-9.05,30.238],[-12.541,26.747],[-12.882,25.924],[-12.882,23.507],[-14.974,18.457],[-15.82,17.611],[-16.161,16.786],[-18.149,15.964],[-19.33,17.146],[-20.976,17.146],[-23.199,14.922],[-23.54,14.099],[-23.54,8.578],[-23.199,7.755],[-19.514,4.07],[-18.691,3.729],[-11.829,3.729],[-11.006,4.07],[-9.054,6.022],[-7.067,5.198],[-7.067,4.082],[-6.726,3.259],[-0.523,-2.944],[5.235,-5.328],[8.844,-5.328],[11.705,-6.513],[17.019,-11.828],[17.019,-16.978],[14.64,-19.357],[11.817,-20.526],[10.013,-20.526],[7.206,-19.365],[3.997,-16.155],[3.174,-15.814],[-0.834,-15.814],[-1.657,-16.155],[-5.227,-19.724],[-5.227,-21.371],[0.072,-26.672],[0.895,-27.012],[7.022,-27.012],[7.845,-27.353],[10.659,-30.167],[10.659,-31.814],[9.711,-32.76],[8.064,-32.76],[7.067,-31.763],[6.243,-31.422],[-2.39,-31.422],[-3.214,-31.082],[-4.304,-29.991],[-5.128,-29.65],[-17.437,-29.65],[-18.26,-29.991],[-21.362,-33.092],[-22.185,-33.432],[-34.695,-33.432],[-39.92,-33.432],[-40.743,-33.092],[-42.493,-31.341],[-43.317,-31],[-48.221,-31],[-49.045,-30.659],[-55.945,-23.759],[-55.962,-22.13],[-53.504,-19.569]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[56.65,43.52]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-0.085,0.085],[0,0],[-1.391,0],[0,0],[-0.972,-0.973],[0,0],[-1.666,0],[-0.329,-0.329],[0,0],[-1.959,0],[-0.739,-0.739],[0,0],[0,-1.336],[0,0],[0.715,-0.716],[0,0],[0,-0.467],[0,0],[1.277,-1.276],[0,0],[0,-1.201],[1.01,-1.01],[0,0],[0,-0.465],[0,0],[-0.33,-0.33],[0,0],[1.566,0],[0.329,0.329],[0,0],[0,0],[0,0.466],[0,0],[0,0],[1.86,1.86],[0,0],[0,0],[0,1.767],[0,0],[-0.188,0.287],[0,0]],"o":[[0,0],[0.984,-0.983],[0,0],[1.375,0],[0,0],[1.177,1.178],[0.465,0],[0,0],[1.385,1.386],[1.045,0],[0,0],[0.945,0.944],[0,0],[0,1.012],[0,0],[-0.329,0.33],[0,0],[0,1.805],[0,0],[-0.849,0.849],[0,1.429],[0,0],[-0.33,0.33],[0,0],[0,0.465],[0,0],[1.108,1.106],[-0.466,0],[0,0],[0,0],[-0.33,-0.329],[0,0],[0,0],[0,-2.631],[0,0],[0,0],[-1.251,-1.25],[0,0],[0,-0.343],[0,0],[0.066,-0.099]],"v":[[-18.799,-28.038],[-15.534,-31.303],[-11.825,-32.839],[-7.757,-32.839],[-4.091,-31.32],[-1.266,-28.496],[3.175,-26.656],[4.417,-26.142],[8.462,-22.097],[13.685,-19.933],[16.471,-18.779],[18.587,-16.663],[20.062,-13.101],[20.062,-12.023],[18.945,-9.325],[15.869,-6.25],[15.355,-5.007],[15.355,-3.231],[13.361,1.582],[2.975,11.968],[1.649,15.171],[0.071,18.98],[-1.898,20.948],[-2.413,22.191],[-2.413,27.397],[-1.898,28.64],[-0.699,29.839],[-1.942,32.839],[-3.184,32.324],[-6.756,28.752],[-10.824,24.684],[-11.339,23.441],[-11.339,5.642],[-11.339,2.314],[-14.244,-4.7],[-15.911,-6.367],[-18.109,-8.565],[-20.062,-13.279],[-20.062,-25.655],[-19.774,-26.62],[-19.026,-27.76]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[67.169,99.76]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0.786,-0.786],[0,0],[0,-0.944],[-0.664,-0.664],[0,0],[0,-0.028],[0.645,-0.646],[0,0],[1.195,0],[0,0],[0.972,-0.971],[0,0],[0,-1.43],[0,0],[-0.506,-0.506],[-0.665,0],[0,0],[-0.831,0.831],[0,0],[-1.332,-1.332],[0,0],[-1.339,0],[0,0],[-0.853,0.852],[0,0],[-1.269,0],[-0.923,-0.924],[0,0],[0,-1.437],[0.873,-0.872],[0,0],[1.389,0],[0,0],[1.175,1.176],[0,0],[1.351,-1.351],[1.84,0],[0,0],[1.295,-1.295],[0,0],[0,-1.357],[0,0],[-1.181,-1.182],[0,0],[-1.596,0],[0,0],[-1.255,-1.255],[0,0],[0,0],[0,-2.121],[0,0],[-1.067,-1.068],[0,0],[0,-1.595],[0,0],[-1.042,-1.041],[0,0],[-1.318,0],[-1.062,1.063],[0,0],[0,1.503],[0,0],[-0.804,0.805],[0,0],[0,1.837],[0,0],[-1.539,1.496],[0,0],[0,1.271],[0,0],[1.097,1.039],[0.019,0.018],[-1.025,1.023],[0,0],[0,1.006],[0,0],[-0.773,0.773],[-0.01,0.01],[-0.796,0],[0,0],[-1.439,-1.44],[0,0],[-1.645,1.644],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[2.198,0],[0,0],[1.004,-1.004],[0,0],[1.305,0],[0,0],[1.005,1.005],[0,0],[1.19,-0.022],[0,0]],"o":[[0,0],[-0.667,0.667],[0,0.938],[0,0],[0.02,0.02],[0,0.913],[0,0],[-0.845,0.845],[0,0],[-1.374,0],[0,0],[-1.011,1.012],[0,0],[0,0.714],[0.47,0.47],[0,0],[1.176,0],[0,0],[1.332,-1.332],[0,0],[0.947,0.948],[0,0],[1.205,0],[0,0],[0.897,-0.897],[1.306,0],[0,0],[1.017,1.016],[0,1.234],[0,0],[-0.983,0.982],[0,0],[-1.663,0],[0,0],[-1.351,-1.351],[-1.3,1.3],[0,0],[-1.832,0],[0,0],[-0.96,0.959],[0,0],[0,1.671],[0,0],[1.128,1.128],[0,0],[1.774,0],[0,0],[0,0],[1.5,1.5],[0,0],[0,1.509],[0,0],[1.128,1.127],[0,0],[0,1.473],[0,0],[0.932,0.932],[1.503,0],[0,0],[1.063,-1.062],[0,0],[0,-1.137],[0,0],[1.299,-1.299],[0,0],[0,-2.146],[0,0],[0.911,-0.887],[0,0],[0,-1.575],[-0.018,-0.017],[-1.049,-0.996],[0,0],[0.712,-0.712],[0,0],[0,-1.109],[0.011,-0.01],[0.563,-0.563],[0,0],[2.036,0],[0,0],[1.644,1.644],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-1.553,1.554],[0,0],[-1.42,0],[0,0],[-0.922,0.922],[0,0],[-1.421,0],[0,0],[-0.842,-0.84],[0,0],[-1.112,0.021]],"v":[[-24.739,-46.716],[-30.254,-41.201],[-31.296,-38.684],[-30.26,-36.182],[-27.782,-33.704],[-27.751,-33.629],[-28.759,-31.195],[-29.155,-30.799],[-32.341,-29.479],[-33.301,-29.479],[-36.965,-27.962],[-41.067,-23.86],[-42.646,-20.047],[-42.646,-18.446],[-41.856,-16.54],[-40.083,-15.806],[-38.321,-15.806],[-35.186,-17.104],[-30.957,-21.333],[-26.133,-21.333],[-25.425,-20.625],[-21.855,-19.146],[-18.996,-19.146],[-15.783,-20.476],[-15.619,-20.641],[-12.236,-22.043],[-8.755,-20.6],[-8.477,-20.322],[-6.889,-16.49],[-8.252,-13.2],[-9.199,-12.252],[-12.902,-10.718],[-19.187,-10.718],[-23.62,-12.555],[-25.325,-14.259],[-30.216,-14.259],[-35.121,-12.228],[-39.785,-12.228],[-44.668,-10.205],[-50.475,-4.398],[-51.974,-0.78],[-51.974,4.274],[-50.129,8.728],[-45.992,12.865],[-41.736,14.627],[-37.699,14.627],[-32.968,16.587],[-29.739,19.816],[-28.95,20.606],[-26.608,26.26],[-26.608,32.717],[-24.942,36.74],[-24.746,36.935],[-22.986,41.186],[-22.986,44.015],[-21.359,47.941],[-18.133,51.167],[-14.619,52.622],[-10.613,50.963],[-6.195,46.545],[-4.535,42.538],[-4.535,37.369],[-3.279,34.335],[-3.255,34.312],[-1.226,29.414],[-1.226,24.594],[1.18,18.897],[6.612,13.615],[8.038,10.24],[8.038,9.858],[6.317,5.802],[6.261,5.75],[6.209,2.066],[9.359,-1.084],[10.47,-3.766],[10.47,-3.847],[11.678,-6.778],[11.71,-6.809],[13.833,-7.688],[15.679,-7.688],[21.106,-5.44],[29.22,2.674],[35.174,2.674],[40.045,-2.197],[43.157,0.916],[43.157,5.326],[48.13,10.298],[55.523,10.298],[58.528,7.293],[58.528,-1.764],[63.165,-6.402],[63.165,-19.059],[65.554,-19.059],[71.261,-24.766],[71.261,-32.03],[65.295,-37.997],[69.965,-42.667],[74.72,-42.667],[74.72,-34.798],[80.168,-29.349],[83.627,-32.808],[83.627,-40.159],[87.13,-43.661],[82.59,-48.201],[71.607,-48.201],[68.711,-51.098],[44.801,-51.098],[42.011,-48.309],[33.213,-48.309],[28.9,-52.622],[19.551,-52.622],[16.351,-49.423],[10.491,-46.995],[5.857,-46.995],[2.071,-45.427],[0.364,-43.72],[-3.114,-42.28],[-9.707,-42.28],[-13.495,-43.849],[-16.38,-46.735],[-19.558,-48.016],[-21.781,-47.974]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[138.554,58.917]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false}],"ip":375,"op":626,"st":375,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Layer 12 Outlines 3","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":250,"s":[-233.69,259.035,0],"to":[0,0,0],"ti":[0,0,0]},{"t":500,"s":[737.31,259.035,0]}]},"a":{"a":0,"k":[113.024,66.425,0]},"s":{"a":0,"k":[200,200,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-0.827,-0.827],[0,0],[-1.045,1.045],[0,0],[-1.506,0],[0,0],[-0.222,0.222],[0,0],[1.437,0],[0,0],[0.222,-0.223],[0,0],[1.516,0],[0,0],[0,0],[0.219,-0.206],[0,0]],"o":[[0,0],[1.045,1.045],[0,0],[1.065,-1.065],[0,0],[0.314,0],[0,0],[1.016,-1.017],[0,0],[-0.314,0],[0,0],[-1.073,1.072],[0,0],[0,0],[-0.3,0],[0,0],[-0.854,0.799]],"v":[[-7.407,2.045],[-6.919,2.533],[-3.136,2.533],[-2.453,1.852],[1.562,0.188],[5.715,0.188],[6.551,-0.158],[7.218,-0.825],[6.077,-3.579],[4.024,-3.579],[3.187,-3.231],[2.946,-2.99],[-1.097,-1.316],[-3.038,-1.316],[-6.474,-1.316],[-7.282,-0.997],[-7.357,-0.926]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[74.11,5.597]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-1.315,-1.315],[0,0],[0,-0.681],[0,0],[-0.742,-0.743],[0,0],[-1.139,1.14],[0,0],[-1.39,0],[0,0],[-0.925,0.925],[0,0],[1.614,1.614],[0.973,0],[0,0],[1.204,-1.204],[0,0],[0.992,0],[0,0],[0.667,-0.684],[0,0]],"o":[[0,0],[0.482,0.482],[0,0],[0,1.05],[0,0],[1.14,1.14],[0,0],[0.982,-0.982],[0,0],[1.308,0],[0,0],[1.614,-1.615],[-0.688,-0.688],[0,0],[-1.703,0],[0,0],[-0.701,0.701],[0,0],[-0.956,0],[0,0],[-1.297,1.332]],"v":[[-13.515,0.856],[-11.507,2.865],[-10.755,4.68],[-10.755,5.354],[-9.595,8.154],[-9.108,8.641],[-4.981,8.641],[-3.177,6.837],[0.528,5.303],[3.022,5.303],[6.509,3.858],[13.23,-2.862],[13.23,-8.707],[10.636,-9.781],[1.499,-9.781],[-3.042,-7.901],[-4.044,-6.898],[-6.688,-5.803],[-10.216,-5.803],[-12.754,-4.733],[-13.547,-3.919]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[94.717,10.032]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-0.461,0.461],[0,0],[-0.309,0],[0,0],[-0.219,-0.219],[0,0],[0,-0.309],[0,0],[0.218,-0.219],[0,0],[0,-0.309],[0,0],[-0.219,-0.218],[0,0],[0,1.037],[0,0],[0.218,0.218],[0,0],[0,0.309],[0,0],[1.34,1.339],[0,0],[0,0.309],[0.734,-0.733],[0,0],[0.454,0.454],[0,0],[0,0.308],[0,0],[-0.218,0.218],[0,0],[-0.309,0],[0,0],[-0.218,-0.219],[0,0],[0,1.038],[0,0],[-0.218,0.218],[0,0],[-2.16,0],[0,0],[-0.759,0.758],[0,0],[1.422,1.422],[0,0],[1.059,0],[0,0],[0.745,-0.744],[0,0],[0.309,0],[0,0],[0.218,0.219],[0,0],[-0.455,0.455],[0,0],[-0.308,0],[0,0],[-0.218,0.219],[0,0],[0.454,0.455],[0,0],[0.455,-0.455],[0,0],[0.31,0],[0,0],[0.219,-0.218],[0,0],[0.309,0],[0,0],[0.218,0.218],[0,0],[0.309,0],[0,0],[0,0],[0.218,-0.218],[0,0],[0.309,0],[0,0],[0.219,-0.219],[0,0],[-0.438,-0.457],[0,0]],"o":[[0,0],[0.219,-0.219],[0,0],[0.309,0],[0,0],[0.218,0.218],[0,0],[0,0.308],[0,0],[-0.219,0.218],[0,0],[0,0.309],[0,0],[0.733,0.734],[0,0],[0,-0.309],[0,0],[-0.218,-0.218],[0,0],[0,-1.894],[0,0],[-0.218,-0.219],[0,-1.037],[0,0],[-0.454,0.454],[0,0],[-0.218,-0.218],[0,0],[0,-0.309],[0,0],[0.218,-0.219],[0,0],[0.308,0],[0,0],[0.734,0.733],[0,0],[0,-0.309],[0,0],[1.527,-1.526],[0,0],[1.073,0],[0,0],[1.422,-1.422],[0,0],[-0.749,-0.749],[0,0],[-1.053,0],[0,0],[-0.218,0.219],[0,0],[-0.309,0],[0,0],[-0.455,-0.455],[0,0],[0.219,-0.218],[0,0],[0.309,0],[0,0],[0.454,-0.455],[0,0],[-0.455,-0.455],[0,0],[-0.219,0.218],[0,0],[-0.309,0],[0,0],[-0.219,0.218],[0,0],[-0.309,0],[0,0],[-0.218,-0.218],[0,0],[0,0],[-0.309,0],[0,0],[-0.219,0.218],[0,0],[-0.309,0],[0,0],[-0.448,0.448],[0,0],[0.452,0.471]],"v":[[-51.841,-19.552],[-49.564,-21.828],[-48.74,-22.169],[-36.561,-22.169],[-35.737,-21.828],[-33.306,-19.397],[-32.966,-18.574],[-32.966,-12.62],[-33.306,-11.797],[-40.493,-4.611],[-40.835,-3.788],[-40.835,2.079],[-40.493,2.902],[-10.697,32.698],[-8.71,31.875],[-8.71,31.061],[-9.05,30.238],[-12.541,26.747],[-12.882,25.924],[-12.882,23.507],[-14.974,18.457],[-15.82,17.611],[-16.161,16.786],[-18.149,15.964],[-19.33,17.146],[-20.976,17.146],[-23.199,14.922],[-23.54,14.099],[-23.54,8.578],[-23.199,7.755],[-19.514,4.07],[-18.691,3.729],[-11.829,3.729],[-11.006,4.07],[-9.054,6.022],[-7.067,5.198],[-7.067,4.082],[-6.726,3.259],[-0.523,-2.944],[5.235,-5.328],[8.844,-5.328],[11.705,-6.513],[17.019,-11.828],[17.019,-16.978],[14.64,-19.357],[11.817,-20.526],[10.013,-20.526],[7.206,-19.365],[3.997,-16.155],[3.174,-15.814],[-0.834,-15.814],[-1.657,-16.155],[-5.227,-19.724],[-5.227,-21.371],[0.072,-26.672],[0.895,-27.012],[7.022,-27.012],[7.845,-27.353],[10.659,-30.167],[10.659,-31.814],[9.711,-32.76],[8.064,-32.76],[7.067,-31.763],[6.243,-31.422],[-2.39,-31.422],[-3.214,-31.082],[-4.304,-29.991],[-5.128,-29.65],[-17.437,-29.65],[-18.26,-29.991],[-21.362,-33.092],[-22.185,-33.432],[-34.695,-33.432],[-39.92,-33.432],[-40.743,-33.092],[-42.493,-31.341],[-43.317,-31],[-48.221,-31],[-49.045,-30.659],[-55.945,-23.759],[-55.962,-22.13],[-53.504,-19.569]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[56.65,43.52]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-0.085,0.085],[0,0],[-1.391,0],[0,0],[-0.972,-0.973],[0,0],[-1.666,0],[-0.329,-0.329],[0,0],[-1.959,0],[-0.739,-0.739],[0,0],[0,-1.336],[0,0],[0.715,-0.716],[0,0],[0,-0.467],[0,0],[1.277,-1.276],[0,0],[0,-1.201],[1.01,-1.01],[0,0],[0,-0.465],[0,0],[-0.33,-0.33],[0,0],[1.566,0],[0.329,0.329],[0,0],[0,0],[0,0.466],[0,0],[0,0],[1.86,1.86],[0,0],[0,0],[0,1.767],[0,0],[-0.188,0.287],[0,0]],"o":[[0,0],[0.984,-0.983],[0,0],[1.375,0],[0,0],[1.177,1.178],[0.465,0],[0,0],[1.385,1.386],[1.045,0],[0,0],[0.945,0.944],[0,0],[0,1.012],[0,0],[-0.329,0.33],[0,0],[0,1.805],[0,0],[-0.849,0.849],[0,1.429],[0,0],[-0.33,0.33],[0,0],[0,0.465],[0,0],[1.108,1.106],[-0.466,0],[0,0],[0,0],[-0.33,-0.329],[0,0],[0,0],[0,-2.631],[0,0],[0,0],[-1.251,-1.25],[0,0],[0,-0.343],[0,0],[0.066,-0.099]],"v":[[-18.799,-28.038],[-15.534,-31.303],[-11.825,-32.839],[-7.757,-32.839],[-4.091,-31.32],[-1.266,-28.496],[3.175,-26.656],[4.417,-26.142],[8.462,-22.097],[13.685,-19.933],[16.471,-18.779],[18.587,-16.663],[20.062,-13.101],[20.062,-12.023],[18.945,-9.325],[15.869,-6.25],[15.355,-5.007],[15.355,-3.231],[13.361,1.582],[2.975,11.968],[1.649,15.171],[0.071,18.98],[-1.898,20.948],[-2.413,22.191],[-2.413,27.397],[-1.898,28.64],[-0.699,29.839],[-1.942,32.839],[-3.184,32.324],[-6.756,28.752],[-10.824,24.684],[-11.339,23.441],[-11.339,5.642],[-11.339,2.314],[-14.244,-4.7],[-15.911,-6.367],[-18.109,-8.565],[-20.062,-13.279],[-20.062,-25.655],[-19.774,-26.62],[-19.026,-27.76]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[67.169,99.76]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0.786,-0.786],[0,0],[0,-0.944],[-0.664,-0.664],[0,0],[0,-0.028],[0.645,-0.646],[0,0],[1.195,0],[0,0],[0.972,-0.971],[0,0],[0,-1.43],[0,0],[-0.506,-0.506],[-0.665,0],[0,0],[-0.831,0.831],[0,0],[-1.332,-1.332],[0,0],[-1.339,0],[0,0],[-0.853,0.852],[0,0],[-1.269,0],[-0.923,-0.924],[0,0],[0,-1.437],[0.873,-0.872],[0,0],[1.389,0],[0,0],[1.175,1.176],[0,0],[1.351,-1.351],[1.84,0],[0,0],[1.295,-1.295],[0,0],[0,-1.357],[0,0],[-1.181,-1.182],[0,0],[-1.596,0],[0,0],[-1.255,-1.255],[0,0],[0,0],[0,-2.121],[0,0],[-1.067,-1.068],[0,0],[0,-1.595],[0,0],[-1.042,-1.041],[0,0],[-1.318,0],[-1.062,1.063],[0,0],[0,1.503],[0,0],[-0.804,0.805],[0,0],[0,1.837],[0,0],[-1.539,1.496],[0,0],[0,1.271],[0,0],[1.097,1.039],[0.019,0.018],[-1.025,1.023],[0,0],[0,1.006],[0,0],[-0.773,0.773],[-0.01,0.01],[-0.796,0],[0,0],[-1.439,-1.44],[0,0],[-1.645,1.644],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[2.198,0],[0,0],[1.004,-1.004],[0,0],[1.305,0],[0,0],[1.005,1.005],[0,0],[1.19,-0.022],[0,0]],"o":[[0,0],[-0.667,0.667],[0,0.938],[0,0],[0.02,0.02],[0,0.913],[0,0],[-0.845,0.845],[0,0],[-1.374,0],[0,0],[-1.011,1.012],[0,0],[0,0.714],[0.47,0.47],[0,0],[1.176,0],[0,0],[1.332,-1.332],[0,0],[0.947,0.948],[0,0],[1.205,0],[0,0],[0.897,-0.897],[1.306,0],[0,0],[1.017,1.016],[0,1.234],[0,0],[-0.983,0.982],[0,0],[-1.663,0],[0,0],[-1.351,-1.351],[-1.3,1.3],[0,0],[-1.832,0],[0,0],[-0.96,0.959],[0,0],[0,1.671],[0,0],[1.128,1.128],[0,0],[1.774,0],[0,0],[0,0],[1.5,1.5],[0,0],[0,1.509],[0,0],[1.128,1.127],[0,0],[0,1.473],[0,0],[0.932,0.932],[1.503,0],[0,0],[1.063,-1.062],[0,0],[0,-1.137],[0,0],[1.299,-1.299],[0,0],[0,-2.146],[0,0],[0.911,-0.887],[0,0],[0,-1.575],[-0.018,-0.017],[-1.049,-0.996],[0,0],[0.712,-0.712],[0,0],[0,-1.109],[0.011,-0.01],[0.563,-0.563],[0,0],[2.036,0],[0,0],[1.644,1.644],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-1.553,1.554],[0,0],[-1.42,0],[0,0],[-0.922,0.922],[0,0],[-1.421,0],[0,0],[-0.842,-0.84],[0,0],[-1.112,0.021]],"v":[[-24.739,-46.716],[-30.254,-41.201],[-31.296,-38.684],[-30.26,-36.182],[-27.782,-33.704],[-27.751,-33.629],[-28.759,-31.195],[-29.155,-30.799],[-32.341,-29.479],[-33.301,-29.479],[-36.965,-27.962],[-41.067,-23.86],[-42.646,-20.047],[-42.646,-18.446],[-41.856,-16.54],[-40.083,-15.806],[-38.321,-15.806],[-35.186,-17.104],[-30.957,-21.333],[-26.133,-21.333],[-25.425,-20.625],[-21.855,-19.146],[-18.996,-19.146],[-15.783,-20.476],[-15.619,-20.641],[-12.236,-22.043],[-8.755,-20.6],[-8.477,-20.322],[-6.889,-16.49],[-8.252,-13.2],[-9.199,-12.252],[-12.902,-10.718],[-19.187,-10.718],[-23.62,-12.555],[-25.325,-14.259],[-30.216,-14.259],[-35.121,-12.228],[-39.785,-12.228],[-44.668,-10.205],[-50.475,-4.398],[-51.974,-0.78],[-51.974,4.274],[-50.129,8.728],[-45.992,12.865],[-41.736,14.627],[-37.699,14.627],[-32.968,16.587],[-29.739,19.816],[-28.95,20.606],[-26.608,26.26],[-26.608,32.717],[-24.942,36.74],[-24.746,36.935],[-22.986,41.186],[-22.986,44.015],[-21.359,47.941],[-18.133,51.167],[-14.619,52.622],[-10.613,50.963],[-6.195,46.545],[-4.535,42.538],[-4.535,37.369],[-3.279,34.335],[-3.255,34.312],[-1.226,29.414],[-1.226,24.594],[1.18,18.897],[6.612,13.615],[8.038,10.24],[8.038,9.858],[6.317,5.802],[6.261,5.75],[6.209,2.066],[9.359,-1.084],[10.47,-3.766],[10.47,-3.847],[11.678,-6.778],[11.71,-6.809],[13.833,-7.688],[15.679,-7.688],[21.106,-5.44],[29.22,2.674],[35.174,2.674],[40.045,-2.197],[43.157,0.916],[43.157,5.326],[48.13,10.298],[55.523,10.298],[58.528,7.293],[58.528,-1.764],[63.165,-6.402],[63.165,-19.059],[65.554,-19.059],[71.261,-24.766],[71.261,-32.03],[65.295,-37.997],[69.965,-42.667],[74.72,-42.667],[74.72,-34.798],[80.168,-29.349],[83.627,-32.808],[83.627,-40.159],[87.13,-43.661],[82.59,-48.201],[71.607,-48.201],[68.711,-51.098],[44.801,-51.098],[42.011,-48.309],[33.213,-48.309],[28.9,-52.622],[19.551,-52.622],[16.351,-49.423],[10.491,-46.995],[5.857,-46.995],[2.071,-45.427],[0.364,-43.72],[-3.114,-42.28],[-9.707,-42.28],[-13.495,-43.849],[-16.38,-46.735],[-19.558,-48.016],[-21.781,-47.974]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[138.554,58.917]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false}],"ip":250,"op":501,"st":250,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Layer 12 Outlines 2","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":125,"s":[-233.69,259.035,0],"to":[0,0,0],"ti":[0,0,0]},{"t":375,"s":[737.31,259.035,0]}]},"a":{"a":0,"k":[113.024,66.425,0]},"s":{"a":0,"k":[200,200,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-0.827,-0.827],[0,0],[-1.045,1.045],[0,0],[-1.506,0],[0,0],[-0.222,0.222],[0,0],[1.437,0],[0,0],[0.222,-0.223],[0,0],[1.516,0],[0,0],[0,0],[0.219,-0.206],[0,0]],"o":[[0,0],[1.045,1.045],[0,0],[1.065,-1.065],[0,0],[0.314,0],[0,0],[1.016,-1.017],[0,0],[-0.314,0],[0,0],[-1.073,1.072],[0,0],[0,0],[-0.3,0],[0,0],[-0.854,0.799]],"v":[[-7.407,2.045],[-6.919,2.533],[-3.136,2.533],[-2.453,1.852],[1.562,0.188],[5.715,0.188],[6.551,-0.158],[7.218,-0.825],[6.077,-3.579],[4.024,-3.579],[3.187,-3.231],[2.946,-2.99],[-1.097,-1.316],[-3.038,-1.316],[-6.474,-1.316],[-7.282,-0.997],[-7.357,-0.926]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[74.11,5.597]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-1.315,-1.315],[0,0],[0,-0.681],[0,0],[-0.742,-0.743],[0,0],[-1.139,1.14],[0,0],[-1.39,0],[0,0],[-0.925,0.925],[0,0],[1.614,1.614],[0.973,0],[0,0],[1.204,-1.204],[0,0],[0.992,0],[0,0],[0.667,-0.684],[0,0]],"o":[[0,0],[0.482,0.482],[0,0],[0,1.05],[0,0],[1.14,1.14],[0,0],[0.982,-0.982],[0,0],[1.308,0],[0,0],[1.614,-1.615],[-0.688,-0.688],[0,0],[-1.703,0],[0,0],[-0.701,0.701],[0,0],[-0.956,0],[0,0],[-1.297,1.332]],"v":[[-13.515,0.856],[-11.507,2.865],[-10.755,4.68],[-10.755,5.354],[-9.595,8.154],[-9.108,8.641],[-4.981,8.641],[-3.177,6.837],[0.528,5.303],[3.022,5.303],[6.509,3.858],[13.23,-2.862],[13.23,-8.707],[10.636,-9.781],[1.499,-9.781],[-3.042,-7.901],[-4.044,-6.898],[-6.688,-5.803],[-10.216,-5.803],[-12.754,-4.733],[-13.547,-3.919]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[94.717,10.032]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-0.461,0.461],[0,0],[-0.309,0],[0,0],[-0.219,-0.219],[0,0],[0,-0.309],[0,0],[0.218,-0.219],[0,0],[0,-0.309],[0,0],[-0.219,-0.218],[0,0],[0,1.037],[0,0],[0.218,0.218],[0,0],[0,0.309],[0,0],[1.34,1.339],[0,0],[0,0.309],[0.734,-0.733],[0,0],[0.454,0.454],[0,0],[0,0.308],[0,0],[-0.218,0.218],[0,0],[-0.309,0],[0,0],[-0.218,-0.219],[0,0],[0,1.038],[0,0],[-0.218,0.218],[0,0],[-2.16,0],[0,0],[-0.759,0.758],[0,0],[1.422,1.422],[0,0],[1.059,0],[0,0],[0.745,-0.744],[0,0],[0.309,0],[0,0],[0.218,0.219],[0,0],[-0.455,0.455],[0,0],[-0.308,0],[0,0],[-0.218,0.219],[0,0],[0.454,0.455],[0,0],[0.455,-0.455],[0,0],[0.31,0],[0,0],[0.219,-0.218],[0,0],[0.309,0],[0,0],[0.218,0.218],[0,0],[0.309,0],[0,0],[0,0],[0.218,-0.218],[0,0],[0.309,0],[0,0],[0.219,-0.219],[0,0],[-0.438,-0.457],[0,0]],"o":[[0,0],[0.219,-0.219],[0,0],[0.309,0],[0,0],[0.218,0.218],[0,0],[0,0.308],[0,0],[-0.219,0.218],[0,0],[0,0.309],[0,0],[0.733,0.734],[0,0],[0,-0.309],[0,0],[-0.218,-0.218],[0,0],[0,-1.894],[0,0],[-0.218,-0.219],[0,-1.037],[0,0],[-0.454,0.454],[0,0],[-0.218,-0.218],[0,0],[0,-0.309],[0,0],[0.218,-0.219],[0,0],[0.308,0],[0,0],[0.734,0.733],[0,0],[0,-0.309],[0,0],[1.527,-1.526],[0,0],[1.073,0],[0,0],[1.422,-1.422],[0,0],[-0.749,-0.749],[0,0],[-1.053,0],[0,0],[-0.218,0.219],[0,0],[-0.309,0],[0,0],[-0.455,-0.455],[0,0],[0.219,-0.218],[0,0],[0.309,0],[0,0],[0.454,-0.455],[0,0],[-0.455,-0.455],[0,0],[-0.219,0.218],[0,0],[-0.309,0],[0,0],[-0.219,0.218],[0,0],[-0.309,0],[0,0],[-0.218,-0.218],[0,0],[0,0],[-0.309,0],[0,0],[-0.219,0.218],[0,0],[-0.309,0],[0,0],[-0.448,0.448],[0,0],[0.452,0.471]],"v":[[-51.841,-19.552],[-49.564,-21.828],[-48.74,-22.169],[-36.561,-22.169],[-35.737,-21.828],[-33.306,-19.397],[-32.966,-18.574],[-32.966,-12.62],[-33.306,-11.797],[-40.493,-4.611],[-40.835,-3.788],[-40.835,2.079],[-40.493,2.902],[-10.697,32.698],[-8.71,31.875],[-8.71,31.061],[-9.05,30.238],[-12.541,26.747],[-12.882,25.924],[-12.882,23.507],[-14.974,18.457],[-15.82,17.611],[-16.161,16.786],[-18.149,15.964],[-19.33,17.146],[-20.976,17.146],[-23.199,14.922],[-23.54,14.099],[-23.54,8.578],[-23.199,7.755],[-19.514,4.07],[-18.691,3.729],[-11.829,3.729],[-11.006,4.07],[-9.054,6.022],[-7.067,5.198],[-7.067,4.082],[-6.726,3.259],[-0.523,-2.944],[5.235,-5.328],[8.844,-5.328],[11.705,-6.513],[17.019,-11.828],[17.019,-16.978],[14.64,-19.357],[11.817,-20.526],[10.013,-20.526],[7.206,-19.365],[3.997,-16.155],[3.174,-15.814],[-0.834,-15.814],[-1.657,-16.155],[-5.227,-19.724],[-5.227,-21.371],[0.072,-26.672],[0.895,-27.012],[7.022,-27.012],[7.845,-27.353],[10.659,-30.167],[10.659,-31.814],[9.711,-32.76],[8.064,-32.76],[7.067,-31.763],[6.243,-31.422],[-2.39,-31.422],[-3.214,-31.082],[-4.304,-29.991],[-5.128,-29.65],[-17.437,-29.65],[-18.26,-29.991],[-21.362,-33.092],[-22.185,-33.432],[-34.695,-33.432],[-39.92,-33.432],[-40.743,-33.092],[-42.493,-31.341],[-43.317,-31],[-48.221,-31],[-49.045,-30.659],[-55.945,-23.759],[-55.962,-22.13],[-53.504,-19.569]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[56.65,43.52]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-0.085,0.085],[0,0],[-1.391,0],[0,0],[-0.972,-0.973],[0,0],[-1.666,0],[-0.329,-0.329],[0,0],[-1.959,0],[-0.739,-0.739],[0,0],[0,-1.336],[0,0],[0.715,-0.716],[0,0],[0,-0.467],[0,0],[1.277,-1.276],[0,0],[0,-1.201],[1.01,-1.01],[0,0],[0,-0.465],[0,0],[-0.33,-0.33],[0,0],[1.566,0],[0.329,0.329],[0,0],[0,0],[0,0.466],[0,0],[0,0],[1.86,1.86],[0,0],[0,0],[0,1.767],[0,0],[-0.188,0.287],[0,0]],"o":[[0,0],[0.984,-0.983],[0,0],[1.375,0],[0,0],[1.177,1.178],[0.465,0],[0,0],[1.385,1.386],[1.045,0],[0,0],[0.945,0.944],[0,0],[0,1.012],[0,0],[-0.329,0.33],[0,0],[0,1.805],[0,0],[-0.849,0.849],[0,1.429],[0,0],[-0.33,0.33],[0,0],[0,0.465],[0,0],[1.108,1.106],[-0.466,0],[0,0],[0,0],[-0.33,-0.329],[0,0],[0,0],[0,-2.631],[0,0],[0,0],[-1.251,-1.25],[0,0],[0,-0.343],[0,0],[0.066,-0.099]],"v":[[-18.799,-28.038],[-15.534,-31.303],[-11.825,-32.839],[-7.757,-32.839],[-4.091,-31.32],[-1.266,-28.496],[3.175,-26.656],[4.417,-26.142],[8.462,-22.097],[13.685,-19.933],[16.471,-18.779],[18.587,-16.663],[20.062,-13.101],[20.062,-12.023],[18.945,-9.325],[15.869,-6.25],[15.355,-5.007],[15.355,-3.231],[13.361,1.582],[2.975,11.968],[1.649,15.171],[0.071,18.98],[-1.898,20.948],[-2.413,22.191],[-2.413,27.397],[-1.898,28.64],[-0.699,29.839],[-1.942,32.839],[-3.184,32.324],[-6.756,28.752],[-10.824,24.684],[-11.339,23.441],[-11.339,5.642],[-11.339,2.314],[-14.244,-4.7],[-15.911,-6.367],[-18.109,-8.565],[-20.062,-13.279],[-20.062,-25.655],[-19.774,-26.62],[-19.026,-27.76]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[67.169,99.76]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0.786,-0.786],[0,0],[0,-0.944],[-0.664,-0.664],[0,0],[0,-0.028],[0.645,-0.646],[0,0],[1.195,0],[0,0],[0.972,-0.971],[0,0],[0,-1.43],[0,0],[-0.506,-0.506],[-0.665,0],[0,0],[-0.831,0.831],[0,0],[-1.332,-1.332],[0,0],[-1.339,0],[0,0],[-0.853,0.852],[0,0],[-1.269,0],[-0.923,-0.924],[0,0],[0,-1.437],[0.873,-0.872],[0,0],[1.389,0],[0,0],[1.175,1.176],[0,0],[1.351,-1.351],[1.84,0],[0,0],[1.295,-1.295],[0,0],[0,-1.357],[0,0],[-1.181,-1.182],[0,0],[-1.596,0],[0,0],[-1.255,-1.255],[0,0],[0,0],[0,-2.121],[0,0],[-1.067,-1.068],[0,0],[0,-1.595],[0,0],[-1.042,-1.041],[0,0],[-1.318,0],[-1.062,1.063],[0,0],[0,1.503],[0,0],[-0.804,0.805],[0,0],[0,1.837],[0,0],[-1.539,1.496],[0,0],[0,1.271],[0,0],[1.097,1.039],[0.019,0.018],[-1.025,1.023],[0,0],[0,1.006],[0,0],[-0.773,0.773],[-0.01,0.01],[-0.796,0],[0,0],[-1.439,-1.44],[0,0],[-1.645,1.644],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[2.198,0],[0,0],[1.004,-1.004],[0,0],[1.305,0],[0,0],[1.005,1.005],[0,0],[1.19,-0.022],[0,0]],"o":[[0,0],[-0.667,0.667],[0,0.938],[0,0],[0.02,0.02],[0,0.913],[0,0],[-0.845,0.845],[0,0],[-1.374,0],[0,0],[-1.011,1.012],[0,0],[0,0.714],[0.47,0.47],[0,0],[1.176,0],[0,0],[1.332,-1.332],[0,0],[0.947,0.948],[0,0],[1.205,0],[0,0],[0.897,-0.897],[1.306,0],[0,0],[1.017,1.016],[0,1.234],[0,0],[-0.983,0.982],[0,0],[-1.663,0],[0,0],[-1.351,-1.351],[-1.3,1.3],[0,0],[-1.832,0],[0,0],[-0.96,0.959],[0,0],[0,1.671],[0,0],[1.128,1.128],[0,0],[1.774,0],[0,0],[0,0],[1.5,1.5],[0,0],[0,1.509],[0,0],[1.128,1.127],[0,0],[0,1.473],[0,0],[0.932,0.932],[1.503,0],[0,0],[1.063,-1.062],[0,0],[0,-1.137],[0,0],[1.299,-1.299],[0,0],[0,-2.146],[0,0],[0.911,-0.887],[0,0],[0,-1.575],[-0.018,-0.017],[-1.049,-0.996],[0,0],[0.712,-0.712],[0,0],[0,-1.109],[0.011,-0.01],[0.563,-0.563],[0,0],[2.036,0],[0,0],[1.644,1.644],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-1.553,1.554],[0,0],[-1.42,0],[0,0],[-0.922,0.922],[0,0],[-1.421,0],[0,0],[-0.842,-0.84],[0,0],[-1.112,0.021]],"v":[[-24.739,-46.716],[-30.254,-41.201],[-31.296,-38.684],[-30.26,-36.182],[-27.782,-33.704],[-27.751,-33.629],[-28.759,-31.195],[-29.155,-30.799],[-32.341,-29.479],[-33.301,-29.479],[-36.965,-27.962],[-41.067,-23.86],[-42.646,-20.047],[-42.646,-18.446],[-41.856,-16.54],[-40.083,-15.806],[-38.321,-15.806],[-35.186,-17.104],[-30.957,-21.333],[-26.133,-21.333],[-25.425,-20.625],[-21.855,-19.146],[-18.996,-19.146],[-15.783,-20.476],[-15.619,-20.641],[-12.236,-22.043],[-8.755,-20.6],[-8.477,-20.322],[-6.889,-16.49],[-8.252,-13.2],[-9.199,-12.252],[-12.902,-10.718],[-19.187,-10.718],[-23.62,-12.555],[-25.325,-14.259],[-30.216,-14.259],[-35.121,-12.228],[-39.785,-12.228],[-44.668,-10.205],[-50.475,-4.398],[-51.974,-0.78],[-51.974,4.274],[-50.129,8.728],[-45.992,12.865],[-41.736,14.627],[-37.699,14.627],[-32.968,16.587],[-29.739,19.816],[-28.95,20.606],[-26.608,26.26],[-26.608,32.717],[-24.942,36.74],[-24.746,36.935],[-22.986,41.186],[-22.986,44.015],[-21.359,47.941],[-18.133,51.167],[-14.619,52.622],[-10.613,50.963],[-6.195,46.545],[-4.535,42.538],[-4.535,37.369],[-3.279,34.335],[-3.255,34.312],[-1.226,29.414],[-1.226,24.594],[1.18,18.897],[6.612,13.615],[8.038,10.24],[8.038,9.858],[6.317,5.802],[6.261,5.75],[6.209,2.066],[9.359,-1.084],[10.47,-3.766],[10.47,-3.847],[11.678,-6.778],[11.71,-6.809],[13.833,-7.688],[15.679,-7.688],[21.106,-5.44],[29.22,2.674],[35.174,2.674],[40.045,-2.197],[43.157,0.916],[43.157,5.326],[48.13,10.298],[55.523,10.298],[58.528,7.293],[58.528,-1.764],[63.165,-6.402],[63.165,-19.059],[65.554,-19.059],[71.261,-24.766],[71.261,-32.03],[65.295,-37.997],[69.965,-42.667],[74.72,-42.667],[74.72,-34.798],[80.168,-29.349],[83.627,-32.808],[83.627,-40.159],[87.13,-43.661],[82.59,-48.201],[71.607,-48.201],[68.711,-51.098],[44.801,-51.098],[42.011,-48.309],[33.213,-48.309],[28.9,-52.622],[19.551,-52.622],[16.351,-49.423],[10.491,-46.995],[5.857,-46.995],[2.071,-45.427],[0.364,-43.72],[-3.114,-42.28],[-9.707,-42.28],[-13.495,-43.849],[-16.38,-46.735],[-19.558,-48.016],[-21.781,-47.974]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[138.554,58.917]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false}],"ip":125,"op":376,"st":125,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Layer 12 Outlines","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.833,"y":0.833},"o":{"x":0.167,"y":0.167},"t":0,"s":[-233.69,259.035,0],"to":[0,0,0],"ti":[0,0,0]},{"t":250,"s":[737.31,259.035,0]}]},"a":{"a":0,"k":[113.024,66.425,0]},"s":{"a":0,"k":[200,200,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-0.827,-0.827],[0,0],[-1.045,1.045],[0,0],[-1.506,0],[0,0],[-0.222,0.222],[0,0],[1.437,0],[0,0],[0.222,-0.223],[0,0],[1.516,0],[0,0],[0,0],[0.219,-0.206],[0,0]],"o":[[0,0],[1.045,1.045],[0,0],[1.065,-1.065],[0,0],[0.314,0],[0,0],[1.016,-1.017],[0,0],[-0.314,0],[0,0],[-1.073,1.072],[0,0],[0,0],[-0.3,0],[0,0],[-0.854,0.799]],"v":[[-7.407,2.045],[-6.919,2.533],[-3.136,2.533],[-2.453,1.852],[1.562,0.188],[5.715,0.188],[6.551,-0.158],[7.218,-0.825],[6.077,-3.579],[4.024,-3.579],[3.187,-3.231],[2.946,-2.99],[-1.097,-1.316],[-3.038,-1.316],[-6.474,-1.316],[-7.282,-0.997],[-7.357,-0.926]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[74.11,5.597]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-1.315,-1.315],[0,0],[0,-0.681],[0,0],[-0.742,-0.743],[0,0],[-1.139,1.14],[0,0],[-1.39,0],[0,0],[-0.925,0.925],[0,0],[1.614,1.614],[0.973,0],[0,0],[1.204,-1.204],[0,0],[0.992,0],[0,0],[0.667,-0.684],[0,0]],"o":[[0,0],[0.482,0.482],[0,0],[0,1.05],[0,0],[1.14,1.14],[0,0],[0.982,-0.982],[0,0],[1.308,0],[0,0],[1.614,-1.615],[-0.688,-0.688],[0,0],[-1.703,0],[0,0],[-0.701,0.701],[0,0],[-0.956,0],[0,0],[-1.297,1.332]],"v":[[-13.515,0.856],[-11.507,2.865],[-10.755,4.68],[-10.755,5.354],[-9.595,8.154],[-9.108,8.641],[-4.981,8.641],[-3.177,6.837],[0.528,5.303],[3.022,5.303],[6.509,3.858],[13.23,-2.862],[13.23,-8.707],[10.636,-9.781],[1.499,-9.781],[-3.042,-7.901],[-4.044,-6.898],[-6.688,-5.803],[-10.216,-5.803],[-12.754,-4.733],[-13.547,-3.919]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[94.717,10.032]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-0.461,0.461],[0,0],[-0.309,0],[0,0],[-0.219,-0.219],[0,0],[0,-0.309],[0,0],[0.218,-0.219],[0,0],[0,-0.309],[0,0],[-0.219,-0.218],[0,0],[0,1.037],[0,0],[0.218,0.218],[0,0],[0,0.309],[0,0],[1.34,1.339],[0,0],[0,0.309],[0.734,-0.733],[0,0],[0.454,0.454],[0,0],[0,0.308],[0,0],[-0.218,0.218],[0,0],[-0.309,0],[0,0],[-0.218,-0.219],[0,0],[0,1.038],[0,0],[-0.218,0.218],[0,0],[-2.16,0],[0,0],[-0.759,0.758],[0,0],[1.422,1.422],[0,0],[1.059,0],[0,0],[0.745,-0.744],[0,0],[0.309,0],[0,0],[0.218,0.219],[0,0],[-0.455,0.455],[0,0],[-0.308,0],[0,0],[-0.218,0.219],[0,0],[0.454,0.455],[0,0],[0.455,-0.455],[0,0],[0.31,0],[0,0],[0.219,-0.218],[0,0],[0.309,0],[0,0],[0.218,0.218],[0,0],[0.309,0],[0,0],[0,0],[0.218,-0.218],[0,0],[0.309,0],[0,0],[0.219,-0.219],[0,0],[-0.438,-0.457],[0,0]],"o":[[0,0],[0.219,-0.219],[0,0],[0.309,0],[0,0],[0.218,0.218],[0,0],[0,0.308],[0,0],[-0.219,0.218],[0,0],[0,0.309],[0,0],[0.733,0.734],[0,0],[0,-0.309],[0,0],[-0.218,-0.218],[0,0],[0,-1.894],[0,0],[-0.218,-0.219],[0,-1.037],[0,0],[-0.454,0.454],[0,0],[-0.218,-0.218],[0,0],[0,-0.309],[0,0],[0.218,-0.219],[0,0],[0.308,0],[0,0],[0.734,0.733],[0,0],[0,-0.309],[0,0],[1.527,-1.526],[0,0],[1.073,0],[0,0],[1.422,-1.422],[0,0],[-0.749,-0.749],[0,0],[-1.053,0],[0,0],[-0.218,0.219],[0,0],[-0.309,0],[0,0],[-0.455,-0.455],[0,0],[0.219,-0.218],[0,0],[0.309,0],[0,0],[0.454,-0.455],[0,0],[-0.455,-0.455],[0,0],[-0.219,0.218],[0,0],[-0.309,0],[0,0],[-0.219,0.218],[0,0],[-0.309,0],[0,0],[-0.218,-0.218],[0,0],[0,0],[-0.309,0],[0,0],[-0.219,0.218],[0,0],[-0.309,0],[0,0],[-0.448,0.448],[0,0],[0.452,0.471]],"v":[[-51.841,-19.552],[-49.564,-21.828],[-48.74,-22.169],[-36.561,-22.169],[-35.737,-21.828],[-33.306,-19.397],[-32.966,-18.574],[-32.966,-12.62],[-33.306,-11.797],[-40.493,-4.611],[-40.835,-3.788],[-40.835,2.079],[-40.493,2.902],[-10.697,32.698],[-8.71,31.875],[-8.71,31.061],[-9.05,30.238],[-12.541,26.747],[-12.882,25.924],[-12.882,23.507],[-14.974,18.457],[-15.82,17.611],[-16.161,16.786],[-18.149,15.964],[-19.33,17.146],[-20.976,17.146],[-23.199,14.922],[-23.54,14.099],[-23.54,8.578],[-23.199,7.755],[-19.514,4.07],[-18.691,3.729],[-11.829,3.729],[-11.006,4.07],[-9.054,6.022],[-7.067,5.198],[-7.067,4.082],[-6.726,3.259],[-0.523,-2.944],[5.235,-5.328],[8.844,-5.328],[11.705,-6.513],[17.019,-11.828],[17.019,-16.978],[14.64,-19.357],[11.817,-20.526],[10.013,-20.526],[7.206,-19.365],[3.997,-16.155],[3.174,-15.814],[-0.834,-15.814],[-1.657,-16.155],[-5.227,-19.724],[-5.227,-21.371],[0.072,-26.672],[0.895,-27.012],[7.022,-27.012],[7.845,-27.353],[10.659,-30.167],[10.659,-31.814],[9.711,-32.76],[8.064,-32.76],[7.067,-31.763],[6.243,-31.422],[-2.39,-31.422],[-3.214,-31.082],[-4.304,-29.991],[-5.128,-29.65],[-17.437,-29.65],[-18.26,-29.991],[-21.362,-33.092],[-22.185,-33.432],[-34.695,-33.432],[-39.92,-33.432],[-40.743,-33.092],[-42.493,-31.341],[-43.317,-31],[-48.221,-31],[-49.045,-30.659],[-55.945,-23.759],[-55.962,-22.13],[-53.504,-19.569]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[56.65,43.52]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 3","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[-0.085,0.085],[0,0],[-1.391,0],[0,0],[-0.972,-0.973],[0,0],[-1.666,0],[-0.329,-0.329],[0,0],[-1.959,0],[-0.739,-0.739],[0,0],[0,-1.336],[0,0],[0.715,-0.716],[0,0],[0,-0.467],[0,0],[1.277,-1.276],[0,0],[0,-1.201],[1.01,-1.01],[0,0],[0,-0.465],[0,0],[-0.33,-0.33],[0,0],[1.566,0],[0.329,0.329],[0,0],[0,0],[0,0.466],[0,0],[0,0],[1.86,1.86],[0,0],[0,0],[0,1.767],[0,0],[-0.188,0.287],[0,0]],"o":[[0,0],[0.984,-0.983],[0,0],[1.375,0],[0,0],[1.177,1.178],[0.465,0],[0,0],[1.385,1.386],[1.045,0],[0,0],[0.945,0.944],[0,0],[0,1.012],[0,0],[-0.329,0.33],[0,0],[0,1.805],[0,0],[-0.849,0.849],[0,1.429],[0,0],[-0.33,0.33],[0,0],[0,0.465],[0,0],[1.108,1.106],[-0.466,0],[0,0],[0,0],[-0.33,-0.329],[0,0],[0,0],[0,-2.631],[0,0],[0,0],[-1.251,-1.25],[0,0],[0,-0.343],[0,0],[0.066,-0.099]],"v":[[-18.799,-28.038],[-15.534,-31.303],[-11.825,-32.839],[-7.757,-32.839],[-4.091,-31.32],[-1.266,-28.496],[3.175,-26.656],[4.417,-26.142],[8.462,-22.097],[13.685,-19.933],[16.471,-18.779],[18.587,-16.663],[20.062,-13.101],[20.062,-12.023],[18.945,-9.325],[15.869,-6.25],[15.355,-5.007],[15.355,-3.231],[13.361,1.582],[2.975,11.968],[1.649,15.171],[0.071,18.98],[-1.898,20.948],[-2.413,22.191],[-2.413,27.397],[-1.898,28.64],[-0.699,29.839],[-1.942,32.839],[-3.184,32.324],[-6.756,28.752],[-10.824,24.684],[-11.339,23.441],[-11.339,5.642],[-11.339,2.314],[-14.244,-4.7],[-15.911,-6.367],[-18.109,-8.565],[-20.062,-13.279],[-20.062,-25.655],[-19.774,-26.62],[-19.026,-27.76]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[67.169,99.76]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 4","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0.786,-0.786],[0,0],[0,-0.944],[-0.664,-0.664],[0,0],[0,-0.028],[0.645,-0.646],[0,0],[1.195,0],[0,0],[0.972,-0.971],[0,0],[0,-1.43],[0,0],[-0.506,-0.506],[-0.665,0],[0,0],[-0.831,0.831],[0,0],[-1.332,-1.332],[0,0],[-1.339,0],[0,0],[-0.853,0.852],[0,0],[-1.269,0],[-0.923,-0.924],[0,0],[0,-1.437],[0.873,-0.872],[0,0],[1.389,0],[0,0],[1.175,1.176],[0,0],[1.351,-1.351],[1.84,0],[0,0],[1.295,-1.295],[0,0],[0,-1.357],[0,0],[-1.181,-1.182],[0,0],[-1.596,0],[0,0],[-1.255,-1.255],[0,0],[0,0],[0,-2.121],[0,0],[-1.067,-1.068],[0,0],[0,-1.595],[0,0],[-1.042,-1.041],[0,0],[-1.318,0],[-1.062,1.063],[0,0],[0,1.503],[0,0],[-0.804,0.805],[0,0],[0,1.837],[0,0],[-1.539,1.496],[0,0],[0,1.271],[0,0],[1.097,1.039],[0.019,0.018],[-1.025,1.023],[0,0],[0,1.006],[0,0],[-0.773,0.773],[-0.01,0.01],[-0.796,0],[0,0],[-1.439,-1.44],[0,0],[-1.645,1.644],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[2.198,0],[0,0],[1.004,-1.004],[0,0],[1.305,0],[0,0],[1.005,1.005],[0,0],[1.19,-0.022],[0,0]],"o":[[0,0],[-0.667,0.667],[0,0.938],[0,0],[0.02,0.02],[0,0.913],[0,0],[-0.845,0.845],[0,0],[-1.374,0],[0,0],[-1.011,1.012],[0,0],[0,0.714],[0.47,0.47],[0,0],[1.176,0],[0,0],[1.332,-1.332],[0,0],[0.947,0.948],[0,0],[1.205,0],[0,0],[0.897,-0.897],[1.306,0],[0,0],[1.017,1.016],[0,1.234],[0,0],[-0.983,0.982],[0,0],[-1.663,0],[0,0],[-1.351,-1.351],[-1.3,1.3],[0,0],[-1.832,0],[0,0],[-0.96,0.959],[0,0],[0,1.671],[0,0],[1.128,1.128],[0,0],[1.774,0],[0,0],[0,0],[1.5,1.5],[0,0],[0,1.509],[0,0],[1.128,1.127],[0,0],[0,1.473],[0,0],[0.932,0.932],[1.503,0],[0,0],[1.063,-1.062],[0,0],[0,-1.137],[0,0],[1.299,-1.299],[0,0],[0,-2.146],[0,0],[0.911,-0.887],[0,0],[0,-1.575],[-0.018,-0.017],[-1.049,-0.996],[0,0],[0.712,-0.712],[0,0],[0,-1.109],[0.011,-0.01],[0.563,-0.563],[0,0],[2.036,0],[0,0],[1.644,1.644],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-1.553,1.554],[0,0],[-1.42,0],[0,0],[-0.922,0.922],[0,0],[-1.421,0],[0,0],[-0.842,-0.84],[0,0],[-1.112,0.021]],"v":[[-24.739,-46.716],[-30.254,-41.201],[-31.296,-38.684],[-30.26,-36.182],[-27.782,-33.704],[-27.751,-33.629],[-28.759,-31.195],[-29.155,-30.799],[-32.341,-29.479],[-33.301,-29.479],[-36.965,-27.962],[-41.067,-23.86],[-42.646,-20.047],[-42.646,-18.446],[-41.856,-16.54],[-40.083,-15.806],[-38.321,-15.806],[-35.186,-17.104],[-30.957,-21.333],[-26.133,-21.333],[-25.425,-20.625],[-21.855,-19.146],[-18.996,-19.146],[-15.783,-20.476],[-15.619,-20.641],[-12.236,-22.043],[-8.755,-20.6],[-8.477,-20.322],[-6.889,-16.49],[-8.252,-13.2],[-9.199,-12.252],[-12.902,-10.718],[-19.187,-10.718],[-23.62,-12.555],[-25.325,-14.259],[-30.216,-14.259],[-35.121,-12.228],[-39.785,-12.228],[-44.668,-10.205],[-50.475,-4.398],[-51.974,-0.78],[-51.974,4.274],[-50.129,8.728],[-45.992,12.865],[-41.736,14.627],[-37.699,14.627],[-32.968,16.587],[-29.739,19.816],[-28.95,20.606],[-26.608,26.26],[-26.608,32.717],[-24.942,36.74],[-24.746,36.935],[-22.986,41.186],[-22.986,44.015],[-21.359,47.941],[-18.133,51.167],[-14.619,52.622],[-10.613,50.963],[-6.195,46.545],[-4.535,42.538],[-4.535,37.369],[-3.279,34.335],[-3.255,34.312],[-1.226,29.414],[-1.226,24.594],[1.18,18.897],[6.612,13.615],[8.038,10.24],[8.038,9.858],[6.317,5.802],[6.261,5.75],[6.209,2.066],[9.359,-1.084],[10.47,-3.766],[10.47,-3.847],[11.678,-6.778],[11.71,-6.809],[13.833,-7.688],[15.679,-7.688],[21.106,-5.44],[29.22,2.674],[35.174,2.674],[40.045,-2.197],[43.157,0.916],[43.157,5.326],[48.13,10.298],[55.523,10.298],[58.528,7.293],[58.528,-1.764],[63.165,-6.402],[63.165,-19.059],[65.554,-19.059],[71.261,-24.766],[71.261,-32.03],[65.295,-37.997],[69.965,-42.667],[74.72,-42.667],[74.72,-34.798],[80.168,-29.349],[83.627,-32.808],[83.627,-40.159],[87.13,-43.661],[82.59,-48.201],[71.607,-48.201],[68.711,-51.098],[44.801,-51.098],[42.011,-48.309],[33.213,-48.309],[28.9,-52.622],[19.551,-52.622],[16.351,-49.423],[10.491,-46.995],[5.857,-46.995],[2.071,-45.427],[0.364,-43.72],[-3.114,-42.28],[-9.707,-42.28],[-13.495,-43.849],[-16.38,-46.735],[-19.558,-48.016],[-21.781,-47.974]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[138.554,58.917]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 5","bm":0,"hd":false}],"ip":0,"op":251,"st":0,"bm":0}]}],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"Null 1","parent":16,"sr":1,"ks":{"o":{"a":0,"k":0},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0]},{"t":251,"s":[-360]}]},"p":{"a":0,"k":[76.409,76.409,0]},"a":{"a":0,"k":[0,0,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"ip":0,"op":3821,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"bulut ufak","parent":16,"sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[6.017,100.189,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":125,"s":[37.017,100.189,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":250,"s":[6.017,100.189,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":375,"s":[37.017,100.189,0],"to":[0,0,0],"ti":[0,0,0]},{"t":500,"s":[6.017,100.189,0]}]},"a":{"a":0,"k":[23.781,15.528,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[3.814,0],[0,0],[2.787,2.786],[5.197,-5.199],[0,0],[0.605,-1.14],[0,0],[0,-5.563],[-5.562,0],[0,0],[0,0],[0,0],[0,3.813]],"o":[[0,0],[0.201,-3.669],[-5.198,-5.199],[0,0],[-0.958,0.957],[0,0],[-5.562,0],[0,5.562],[0,0],[0,0],[0,0],[3.814,0],[0,-3.814]],"v":[[16.598,0.761],[12.713,0.761],[8.85,-9.429],[-10.051,-9.429],[-10.715,-8.765],[-13.055,-5.598],[-13.419,-5.598],[-23.532,4.516],[-13.419,14.628],[-3.622,14.628],[-0.04,14.628],[16.598,14.628],[23.532,7.696]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.879527671664,0.954818546071,0.972975786994,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[23.781,14.878]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":3821,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"bulut buyuk","parent":16,"sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[150.786,61.201,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":125,"s":[132.286,61.201,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":250,"s":[150.786,61.201,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":375,"s":[132.286,61.201,0],"to":[0,0,0],"ti":[0,0,0]},{"t":500,"s":[150.786,61.201,0]}]},"a":{"a":0,"k":[40.184,26.178,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[6.472,0],[0,0],[4.728,4.729],[8.821,-8.821],[0,0],[1.026,-1.935],[0,0],[0,-9.44],[-9.44,0],[0,0],[0,0],[0,0],[0,6.472]],"o":[[0,0],[0.342,-6.226],[-8.821,-8.821],[0,0],[-1.624,1.624],[0,0],[-9.44,0],[0,9.44],[0,0],[0,0],[0,0],[6.472,0],[0,-6.472]],"v":[[28.168,1.291],[21.573,1.291],[15.018,-16.004],[-17.056,-16.004],[-18.185,-14.877],[-22.154,-9.502],[-22.771,-9.502],[-39.935,7.662],[-22.771,24.825],[-6.146,24.825],[-0.069,24.825],[28.168,24.825],[39.935,13.058]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.879527671664,0.954818546071,0.972975786994,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[40.184,25.075]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":3821,"st":0,"bm":0},{"ddd":0,"ind":6,"ty":4,"nm":"pin","parent":16,"sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":1,"k":[{"i":{"x":0.338,"y":1},"o":{"x":0.167,"y":0},"t":6,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.627,"y":0},"t":11,"s":[76.409,29.098,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":16,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":20,"s":[76.409,36.848,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":24,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.338,"y":1},"o":{"x":0.167,"y":0},"t":56,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.627,"y":0},"t":61,"s":[76.409,29.098,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":66,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":70,"s":[76.409,36.848,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":74,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.338,"y":1},"o":{"x":0.167,"y":0},"t":106,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.627,"y":0},"t":111,"s":[76.409,29.098,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":116,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":120,"s":[76.409,36.848,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":124,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.338,"y":1},"o":{"x":0.167,"y":0},"t":156,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.627,"y":0},"t":161,"s":[76.409,29.098,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":166,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":170,"s":[76.409,36.848,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":0.667},"o":{"x":0.167,"y":0.167},"t":174,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.338,"y":1},"o":{"x":0.167,"y":0},"t":206,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.627,"y":0},"t":211,"s":[76.409,29.098,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":216,"s":[76.409,40.348,0],"to":[0,0,0],"ti":[0,0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.167,"y":0},"t":220,"s":[76.409,36.848,0],"to":[0,0,0],"ti":[0,0,0]},{"t":224,"s":[76.409,40.348,0]}]},"a":{"a":0,"k":[25.197,70.234,0]},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100]},{"i":{"x":[0.504,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":6,"s":[100,86,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.627,0.167,0.167],"y":[0,0,0]},"t":11,"s":[100,110,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":16,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":20,"s":[100,98,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":50,"s":[100,100,100]},{"i":{"x":[0.504,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":56,"s":[100,86,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.627,0.167,0.167],"y":[0,0,0]},"t":61,"s":[100,110,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":66,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":70,"s":[100,98,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":100,"s":[100,100,100]},{"i":{"x":[0.504,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":106,"s":[100,86,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.627,0.167,0.167],"y":[0,0,0]},"t":111,"s":[100,110,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":116,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":120,"s":[100,98,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":150,"s":[100,100,100]},{"i":{"x":[0.504,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":156,"s":[100,86,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.627,0.167,0.167],"y":[0,0,0]},"t":161,"s":[100,110,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":166,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":170,"s":[100,98,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":200,"s":[100,100,100]},{"i":{"x":[0.504,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":206,"s":[100,86,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.627,0.167,0.167],"y":[0,0,0]},"t":211,"s":[100,110,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":216,"s":[100,100,100]},{"t":220,"s":[100,98,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,-6.486],[6.486,0],[0,6.486],[-6.486,0]],"o":[[0,6.486],[-6.486,0],[0,-6.486],[6.486,0]],"v":[[11.744,0],[0,11.744],[-11.744,0],[0,-11.744]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.999999820485,0.999999760646,0.999999820485,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[25.198,25.197]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false},{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,-13.778],[0,0],[0,13.778],[-13.778,0]],"o":[[0,13.778],[0,0],[0,-13.778],[13.778,0]],"v":[[24.948,-10.287],[0,35.234],[-24.948,-10.287],[0,-35.234]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.054957730162,0.070507887298,0.144140131333,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[25.197,35.484]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 2","bm":0,"hd":false}],"ip":0,"op":3821,"st":0,"bm":0},{"ddd":0,"ind":7,"ty":4,"nm":"Layer 14 Outlines","parent":16,"sr":1,"ks":{"o":{"a":1,"k":[{"i":{"x":[0.389],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":6,"s":[50]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.709],"y":[0]},"t":11,"s":[25]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":16,"s":[50]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":20,"s":[25]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":24,"s":[50]},{"i":{"x":[0.389],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":56,"s":[50]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.709],"y":[0]},"t":61,"s":[25]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":66,"s":[50]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":70,"s":[25]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":74,"s":[50]},{"i":{"x":[0.389],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":106,"s":[50]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.709],"y":[0]},"t":111,"s":[25]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":116,"s":[50]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":120,"s":[25]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":124,"s":[50]},{"i":{"x":[0.389],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":156,"s":[50]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.709],"y":[0]},"t":161,"s":[25]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":166,"s":[50]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":170,"s":[25]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":174,"s":[50]},{"i":{"x":[0.389],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":206,"s":[50]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.709],"y":[0]},"t":211,"s":[25]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.333],"y":[0]},"t":216,"s":[50]},{"i":{"x":[0.667],"y":[1]},"o":{"x":[0.167],"y":[0]},"t":220,"s":[25]},{"t":224,"s":[50]}]},"r":{"a":0,"k":0},"p":{"a":0,"k":[76.409,40.275,0]},"a":{"a":0,"k":[6.799,2.486,0]},"s":{"a":1,"k":[{"i":{"x":[0.389,0.389,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":6,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.709,0.709,0.333],"y":[0,0,0]},"t":11,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":16,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":20,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":24,"s":[100,100,100]},{"i":{"x":[0.389,0.389,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":56,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.709,0.709,0.333],"y":[0,0,0]},"t":61,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":66,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":70,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":74,"s":[100,100,100]},{"i":{"x":[0.389,0.389,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":106,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.709,0.709,0.333],"y":[0,0,0]},"t":111,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":116,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":120,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":124,"s":[100,100,100]},{"i":{"x":[0.389,0.389,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":156,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.709,0.709,0.333],"y":[0,0,0]},"t":161,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":166,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":170,"s":[50,50,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":174,"s":[100,100,100]},{"i":{"x":[0.389,0.389,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":206,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.709,0.709,0.333],"y":[0,0,0]},"t":211,"s":[0,0,100]},{"i":{"x":[0.833,0.833,0.833],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":216,"s":[100,100,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.167,0.167,0.167],"y":[0,0,0]},"t":220,"s":[50,50,100]},{"t":224,"s":[100,100,100]}]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,-1.373],[3.755,0],[0,1.373],[-3.754,0]],"o":[[0,1.373],[-3.754,0],[0,-1.373],[3.755,0]],"v":[[6.799,0],[0,2.486],[-6.798,0],[0,-2.486]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.054957730162,0.070507887298,0.144140131333,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[6.799,2.486]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":3821,"st":0,"bm":0},{"ddd":0,"ind":9,"ty":4,"nm":"Layer 4 Outlines","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[64.544,62.967,0]},"a":{"a":0,"k":[6.573,6.573,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,-3.492],[3.492,0],[0,3.492],[-3.492,0]],"o":[[0,3.492],[-3.492,0],[0,-3.492],[3.492,0]],"v":[[6.323,0],[0.001,6.323],[-6.323,0],[0.001,-6.323]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.341009820676,0.636483106426,0.527247829063,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[6.573,6.573]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":3821,"st":0,"bm":0},{"ddd":0,"ind":10,"ty":4,"nm":"Layer 5 Outlines","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[64.544,62.967,0]},"a":{"a":0,"k":[11.067,11.067,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,-5.422],[5.421,0],[0,5.422],[-5.422,0]],"o":[[0,5.422],[-5.422,0],[0,-5.422],[5.421,0]],"v":[[9.817,0],[0.001,9.816],[-9.817,0],[0.001,-9.816]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.341009820676,0.636483106426,0.527247829063,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0.5},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[11.067,11.067]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":3821,"st":0,"bm":0},{"ddd":0,"ind":11,"ty":4,"nm":"Layer 6 Outlines","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[-60.058,-67.299,0]},"a":{"a":0,"k":[11.112,11.112,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,-5.422],[5.421,0],[0,5.422],[-5.422,0]],"o":[[0,5.422],[-5.422,0],[0,-5.422],[5.421,0]],"v":[[9.817,0],[0,9.817],[-9.817,0],[0,-9.817]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":0.518},"lc":1,"lj":1,"ml":10,"bm":0,"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[11.112,11.112]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":3821,"st":0,"bm":0},{"ddd":0,"ind":12,"ty":4,"nm":"Layer 7 Outlines","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[-59.995,-67.299,0]},"a":{"a":0,"k":[6.573,6.573,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,-3.492],[3.492,0],[0,3.492],[-3.491,0]],"o":[[0,3.492],[-3.491,0],[0,-3.492],[3.492,0]],"v":[[6.323,0],[0,6.323],[-6.323,0],[0,-6.323]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[6.573,6.573]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":3821,"st":0,"bm":0},{"ddd":0,"ind":13,"ty":4,"nm":"Layer 11 Outlines","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[0,0.001,0]},"a":{"a":0,"k":[92.631,92.631,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,-49.728],[49.729,0],[0,49.729],[-49.729,0]],"o":[[0,49.729],[-49.729,0],[0,-49.728],[49.729,0]],"v":[[90.042,-0.001],[-0.001,90.042],[-90.042,-0.001],[-0.001,-90.042]],"c":true}},"nm":"Path 1","hd":false},{"ty":"st","c":{"a":0,"k":[0.90418066885,0.768218455595,0.224159659591,1]},"o":{"a":0,"k":100},"w":{"a":0,"k":1.036},"lc":1,"lj":1,"ml":10,"bm":0,"d":[{"n":"d","nm":"dash","v":{"a":0,"k":3.109}},{"n":"g","nm":"gap","v":{"a":0,"k":3.109}},{"n":"o","nm":"offset","v":{"a":0,"k":0}}],"nm":"Stroke 1","hd":false},{"ty":"tr","p":{"a":0,"k":[92.631,92.631]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":3821,"st":0,"bm":0},{"ddd":0,"ind":14,"ty":4,"nm":"Layer 1 Outlines 2","parent":16,"td":1,"sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[76.409,76.409,0]},"a":{"a":0,"k":[76.409,76.409,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,-42.062],[42.061,0],[0,42.062],[-42.062,0]],"o":[[0,42.062],[-42.062,0],[0,-42.062],[42.061,0]],"v":[[76.159,0],[0,76.159],[-76.159,0],[0,-76.159]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.341009820676,0.636483106426,0.527247829063,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[76.409,76.409]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":3821,"st":0,"bm":0},{"ddd":0,"ind":15,"ty":0,"nm":"harita","tt":1,"refId":"comp_0","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[250,250,0]},"a":{"a":0,"k":[250,250,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"w":500,"h":500,"ip":0,"op":3696,"st":-125,"bm":0},{"ddd":0,"ind":16,"ty":4,"nm":"earth","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[250,250,0]},"a":{"a":0,"k":[76.409,76.409,0]},"s":{"a":0,"k":[200,200,100]}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ks":{"a":0,"k":{"i":[[0,-42.062],[42.061,0],[0,42.062],[-42.062,0]],"o":[[0,42.062],[-42.062,0],[0,-42.062],[42.061,0]],"v":[[76.159,0],[0,76.159],[-76.159,0],[0,-76.159]],"c":true}},"nm":"Path 1","hd":false},{"ty":"fl","c":{"a":0,"k":[0.341009820676,0.636483106426,0.527247829063,1]},"o":{"a":0,"k":100},"r":1,"bm":0,"nm":"Fill 1","hd":false},{"ty":"tr","p":{"a":0,"k":[76.409,76.409]},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"r":{"a":0,"k":0},"o":{"a":0,"k":100},"sk":{"a":0,"k":0},"sa":{"a":0,"k":0},"nm":"Transform"}],"nm":"Group 1","bm":0,"hd":false}],"ip":0,"op":3821,"st":0,"bm":0}],"markers":[{"tm":0,"cm":"1","dr":0},{"tm":150,"cm":"2","dr":0}]} \ No newline at end of file diff --git a/assets/lotties/no_internet.json b/assets/lotties/no_internet.json new file mode 100644 index 0000000..fcb7dae --- /dev/null +++ b/assets/lotties/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/assets/lotties/result page succes.json b/assets/lotties/result page succes.json new file mode 100644 index 0000000..4e78511 --- /dev/null +++ b/assets/lotties/result page succes.json @@ -0,0 +1 @@ +{"nm":"result page success motion","ddd":0,"h":720,"w":720,"meta":{"g":"@lottiefiles/toolkit-js 0.66.1","tc":"#ffffff"},"layers":[{"ty":4,"nm":"Layer 5","sr":1,"st":0,"op":120,"ip":0,"ln":"292","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[136,80,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[360,364,0],"x":"var $bm_rt;\nvar $bm_rt;\nvar eff, amp, freq, decay, n, n, t, t, v;\ntry {\n eff = effect('Elastic Controller');\n amp = div(eff(1), 250);\n freq = div(eff(2), 30);\n decay = div(eff(3), 10);\n $bm_rt = $bm_rt = n = 0;\n if ($bm_sum(numKeys, 0)) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if ($bm_sum(key(n).time, time)) {\n n--;\n }\n }\n if ($bm_sum(n, 0)) {\n $bm_rt = $bm_rt = t = 0;\n } else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n }\n if ($bm_sum(n, 0)) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n } else {\n $bm_rt = $bm_rt = value;\n }\n} catch (e) {\n $bm_rt = $bm_rt = value = value;\n}"},"r":{"a":0,"k":0,"x":"var $bm_rt;\nvar $bm_rt;\nvar eff, amp, freq, decay, n, n, t, t, v;\ntry {\n eff = effect('Elastic Controller');\n amp = div(eff(1), 200);\n freq = div(eff(2), 30);\n decay = div(eff(3), 10);\n $bm_rt = $bm_rt = n = 0;\n if ($bm_sum(numKeys, 0)) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if ($bm_sum(key(n).time, time)) {\n n--;\n }\n }\n if ($bm_sum(n, 0)) {\n $bm_rt = $bm_rt = t = 0;\n } else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n }\n if ($bm_sum(n, 0)) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n } else {\n $bm_rt = $bm_rt = value;\n }\n} catch (e) {\n $bm_rt = $bm_rt = value = value;\n}"},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"gr","nm":"Group 1","it":[{"ty":"gr","nm":"Group 1","it":[{"ty":"gr","nm":"Group 2","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0]],"v":[[84.762,84.375],[121.762,121.375],[197.962,45.175]]}}},{"ty":"tm","nm":"Trim Paths 1","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[100],"t":51.818},{"s":[0],"t":53.181}]},"o":{"a":0,"k":0},"s":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.833},"s":[0],"t":0},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":10.908},{"o":{"x":0.167,"y":0.167},"i":{"x":0.667,"y":1},"s":[100],"t":51.818},{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.833},"s":[0],"t":53.181},{"s":[100],"t":60}]},"m":1},{"ty":"st","nm":"描边 1","lc":2,"lj":2,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":20},"c":{"a":0,"k":[1,1,1]}},{"ty":"tr","a":{"a":0,"k":[137.47,86.757]},"s":{"a":0,"k":[120,120]},"p":{"a":1,"k":[{"o":{"x":0.333,"y":0},"i":{"x":0.833,"y":0.856},"s":[138.32,85.641],"t":0},{"o":{"x":0.167,"y":0.167},"i":{"x":1,"y":1},"s":[186.856,50],"t":10.908},{"h":1,"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.833},"s":[186.856,50],"t":47.726},{"o":{"x":0,"y":0},"i":{"x":0.833,"y":1},"s":[135,142],"t":53.181},{"s":[135,90],"t":60}],"x":"var $bm_rt;\nvar $bm_rt;\nvar eff, amp, freq, decay, n, n, t, t, v;\ntry {\n eff = effect('Elastic Controller 3');\n amp = div(eff(1), 200);\n freq = div(eff(2), 30);\n decay = div(eff(3), 10);\n $bm_rt = $bm_rt = n = 0;\n if ($bm_sum(numKeys, 0)) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if ($bm_sum(key(n).time, time)) {\n n--;\n }\n }\n if ($bm_sum(n, 0)) {\n $bm_rt = $bm_rt = t = 0;\n } else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n }\n if ($bm_sum(n, 0)) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n } else {\n $bm_rt = $bm_rt = value;\n }\n} catch (e) {\n $bm_rt = $bm_rt = value = value;\n}"},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"gr","nm":"椭圆 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[67.797,0],[0,-67.797],[-67.797,0],[0,67.797]],"o":[[-67.797,0],[0,67.797],[67.797,0],[0,-67.797]],"v":[[-104,15.242],[-226.758,138],[-104,260.758],[18.758,138]]}}},{"ty":"fl","nm":"填充 1","c":{"a":0,"k":[0.149,0.6667,0.2627]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[-105.582,142.903]},"s":{"a":0,"k":[142.01,142.01]},"p":{"a":0,"k":[137.419,87.526]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tr","a":{"a":0,"k":[134.519,89.974]},"s":{"a":1,"k":[{"s":[100,100],"i":{"x":[0.09,0.09],"y":[1,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":4.092},{"s":[-100,100],"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.79,0.79],"y":[0,0]},"t":31.363},{"s":[100,100],"i":{"x":[0.833,0.833],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":57.273}]},"p":{"a":0,"k":[134.519,89.974]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tr","a":{"a":0,"k":[134.488,89.974]},"s":{"a":1,"k":[{"s":[100,100],"i":{"x":[0.833,0.833],"y":[0.833,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":9.547},{"s":[80,100],"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":15},{"s":[80,100],"i":{"x":[0.833,0.833],"y":[0.833,1]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":45},{"s":[100,100],"i":{"x":[0.833,0.833],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":50.457}],"x":"var $bm_rt;\nvar $bm_rt;\nvar eff, amp, freq, decay, n, n, t, t, v;\ntry {\n eff = effect('Elastic Controller 2');\n amp = div(eff(1), 200);\n freq = div(eff(2), 30);\n decay = div(eff(3), 10);\n $bm_rt = $bm_rt = n = 0;\n if ($bm_sum(numKeys, 0)) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if ($bm_sum(key(n).time, time)) {\n n--;\n }\n }\n if ($bm_sum(n, 0)) {\n $bm_rt = $bm_rt = t = 0;\n } else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n }\n if ($bm_sum(n, 0)) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n } else {\n $bm_rt = $bm_rt = value;\n }\n} catch (e) {\n $bm_rt = $bm_rt = value = value;\n}"},"p":{"a":0,"k":[134.488,89.974]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]},{"ty":"tr","a":{"a":0,"k":[134.519,89.962]},"s":{"a":1,"k":[{"s":[100,100],"i":{"x":[0.833,0.833],"y":[1,0.833]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":9.547},{"s":[100,80],"i":{"x":[0.667,0.667],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":15},{"s":[100,80],"i":{"x":[0.833,0.833],"y":[1,0.833]},"o":{"x":[0.333,0.333],"y":[0,0]},"t":50.457},{"s":[100,100],"i":{"x":[0.833,0.833],"y":[1,1]},"o":{"x":[0.167,0.167],"y":[0,0]},"t":55.908}],"x":"var $bm_rt;\nvar $bm_rt;\nvar eff, amp, freq, decay, n, n, t, t, v;\ntry {\n eff = effect('Elastic Controller 2');\n amp = div(eff(1), 200);\n freq = div(eff(2), 30);\n decay = div(eff(3), 10);\n $bm_rt = $bm_rt = n = 0;\n if ($bm_sum(numKeys, 0)) {\n $bm_rt = $bm_rt = n = nearestKey(time).index;\n if ($bm_sum(key(n).time, time)) {\n n--;\n }\n }\n if ($bm_sum(n, 0)) {\n $bm_rt = $bm_rt = t = 0;\n } else {\n $bm_rt = $bm_rt = t = sub(time, key(n).time);\n }\n if ($bm_sum(n, 0)) {\n v = velocityAtTime(sub(key(n).time, div(thisComp.frameDuration, 10)));\n $bm_rt = $bm_rt = add(value, div(mul(mul(v, amp), Math.sin(mul(mul(mul(freq, t), 2), Math.PI))), Math.exp(mul(decay, t))));\n } else {\n $bm_rt = $bm_rt = value;\n }\n} catch (e) {\n $bm_rt = $bm_rt = value = value;\n}"},"p":{"a":0,"k":[134.519,89.962]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":1},{"ty":0,"nm":"confetti","sr":1,"st":0,"op":180,"ip":0,"ln":"342","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[960,960]},"s":{"a":0,"k":[54.167,54.167,97.015]},"p":{"a":0,"k":[360,360]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}},"w":1920,"h":1920,"refId":"1","ind":2}],"v":"5.7.0","fr":60,"op":120,"ip":0,"assets":[{"nm":"confetti","id":"1","layers":[{"ty":4,"nm":"Element 11","sr":1,"st":9,"op":1809,"ip":9,"ln":"330","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[88.379,84.132,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.174,"y":0},"i":{"x":0.33,"y":1},"s":[957.368,957.581,0],"t":11.83,"ti":[34.333,-69,0],"to":[-154.333,61,0]},{"s":[774.545,1455.048,0],"t":34.338}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[-17.539,13.457],[-12.743,-16.607],[0,0]],"o":[[-12.743,-16.608],[17.539,-13.458],[0,0],[0,0]],"v":[[-25.635,34.132],[-16.894,-20.673],[38.306,-14.931],[38.379,-14.837]]}}},{"ty":"st","nm":"Stroke 1","lc":2,"lj":2,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":20},"c":{"a":0,"k":[0.255,0.686,0.502]}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[88.378,84.131]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":1},{"ty":4,"nm":"Element 10","sr":1,"st":9,"op":1809,"ip":9,"ln":"329","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[33.514,41.664,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.229,"y":0},"i":{"x":0.33,"y":1},"s":[960,960,0],"t":11.83,"ti":[-71.667,-48,0],"to":[59.019,77.842,0]},{"s":[1314.113,1427.051,0],"t":37.154}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[3.281,-1.895],[0,0],[1.894,3.281],[0,0],[-3.281,1.894],[0,0],[-1.894,-3.282],[0,0]],"o":[[0,0],[-3.281,1.894],[0,0],[-1.894,-3.283],[0,0],[3.281,-1.895],[0,0],[1.894,3.281]],"v":[[28.859,29.055],[10.732,39.52],[1.361,37.009],[-31.37,-19.681],[-28.859,-29.053],[-10.732,-39.519],[-1.361,-37.008],[31.37,19.683]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[0.275,0.514,0.961]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[33.514,41.664]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":2},{"ty":4,"nm":"Element 9","sr":1,"st":9,"op":1809,"ip":9,"ln":"328","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[60.548,78.82,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.189,"y":0},"i":{"x":0.33,"y":1},"s":[961.262,961.213,0],"t":11.83,"ti":[-62.333,-30.333,0],"to":[70.333,-5.667,0]},{"s":[1420.768,1239.936,0],"t":35.043}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[2.886,-21.917],[20.753,2.734],[0.039,0.005]],"o":[[20.754,2.733],[-2.886,21.918],[-0.04,-0.005],[0,0]],"v":[[-12.409,-41.32],[20.162,3.615],[-22.93,38.586],[-23.048,38.571]]}}},{"ty":"st","nm":"Stroke 1","lc":2,"lj":2,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":15},"c":{"a":0,"k":[0.9725,0.149,0.5059]}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[60.548,78.82]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":3},{"ty":4,"nm":"Element 8","sr":1,"st":9,"op":1809,"ip":9,"ln":"327","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[47.326,47.325,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.263,"y":0},"i":{"x":0.33,"y":1},"s":[959.692,959.692,0],"t":11.83,"ti":[-98.333,51.667,0],"to":[46.333,-81.667,0]},{"s":[1397.413,873.271,0],"t":52.632}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[-11.644,-3.948],[0,0],[-3.912,2.92],[0,0],[0.157,-12.294],[0,0],[-3.985,-2.819],[0,0],[11.74,-3.65],[0,0],[1.449,-4.662],[0,0],[7.098,10.038],[0,0],[4.882,-0.063],[0,0],[-7.354,9.853],[0,0],[1.569,4.624],[0,0]],"o":[[0,0],[4.623,1.568],[0,0],[9.854,-7.353],[0,0],[-0.062,4.882],[0,0],[10.038,7.099],[0,0],[-4.662,1.449],[0,0],[-3.649,11.74],[0,0],[-2.82,-3.986],[0,0],[-12.293,0.156],[0,0],[2.92,-3.913],[0,0],[-3.948,-11.644]],"v":[[-17.558,-36.37],[-13.481,-34.987],[0.154,-37.147],[3.606,-39.722],[27.31,-27.644],[27.255,-23.339],[33.522,-11.038],[37.038,-8.552],[32.877,17.724],[28.763,19.002],[19.003,28.764],[17.724,32.876],[-8.551,37.037],[-11.037,33.522],[-23.338,27.255],[-27.644,27.31],[-39.721,3.606],[-37.146,0.155],[-34.987,-13.481],[-36.37,-17.558]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[1,0.737,0.196]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[47.325,47.325]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":4},{"ty":4,"nm":"Element 7","sr":1,"st":9,"op":1809,"ip":9,"ln":"326","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[77.856,74.774,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.229,"y":0},"i":{"x":0.33,"y":1},"s":[958.09,958.244,0],"t":11.83,"ti":[-64.333,76,0],"to":[18.333,-110,0]},{"s":[1316.148,645.839,0],"t":37.154}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":false,"i":[[0,0],[-12.731,9.768],[-9.249,-12.055],[-0.018,-0.022]],"o":[[-9.249,-12.055],[12.73,-9.768],[0.018,0.023],[0,0]],"v":[[-18.607,24.774],[-12.262,-15.006],[27.805,-10.838],[27.857,-10.77]]}}},{"ty":"st","nm":"Stroke 1","lc":2,"lj":2,"ml":4,"o":{"a":0,"k":100},"w":{"a":0,"k":20},"c":{"a":0,"k":[0.9725,0.149,0.5059]}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[77.856,74.774]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":5},{"ty":4,"nm":"Element 6","sr":1,"st":9,"op":1809,"ip":9,"ln":"325","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[47.326,47.325,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.189,"y":0},"i":{"x":0.33,"y":1},"s":[959.693,959.693,0],"t":11.83,"ti":[110,-64.333,0],"to":[-138,24.333,0]},{"s":[529.471,1363.811,0],"t":35.043}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[-11.644,-3.948],[0,0],[-3.913,2.92],[0,0],[0.157,-12.294],[0,0],[-3.986,-2.819],[0,0],[11.74,-3.65],[0,0],[1.449,-4.662],[0,0],[7.099,10.038],[0,0],[4.881,-0.062],[0,0],[-7.353,9.853],[0,0],[1.568,4.624],[0,0]],"o":[[0,0],[4.623,1.567],[0,0],[9.853,-7.352],[0,0],[-0.063,4.882],[0,0],[10.038,7.1],[0,0],[-4.662,1.449],[0,0],[-3.649,11.741],[0,0],[-2.819,-3.987],[0,0],[-12.293,0.157],[0,0],[2.92,-3.913],[0,0],[-3.948,-11.644]],"v":[[-17.558,-36.37],[-13.481,-34.987],[0.154,-37.147],[3.605,-39.723],[27.31,-27.644],[27.255,-23.339],[33.521,-11.038],[37.037,-8.553],[32.875,17.724],[28.763,19.002],[19.003,28.764],[17.724,32.875],[-8.552,37.037],[-11.039,33.522],[-23.339,27.254],[-27.645,27.31],[-39.722,3.606],[-37.146,0.154],[-34.987,-13.481],[-36.37,-17.558]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[1,0.7373,0.1961]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[47.325,47.325]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":6},{"ty":4,"nm":"Element 5","sr":1,"st":9,"op":1809,"ip":9,"ln":"324","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[30.771,30.771,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.283,"y":0},"i":{"x":0.33,"y":1},"s":[960,960,0],"t":11.83,"ti":[124.333,-150,0],"to":[-178.333,3,0]},{"s":[517.297,1177.159,0],"t":54.039}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,-16.856],[16.856,0],[0,16.856],[-16.857,0]],"o":[[0,16.856],[-16.857,0],[0,-16.856],[16.856,0]],"v":[[30.521,0],[0,30.521],[-30.521,0],[0,-30.521]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[0.9725,0.149,0.5059]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[30.771,30.771]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":7},{"ty":4,"nm":"Element 4","sr":1,"st":9,"op":1809,"ip":9,"ln":"323","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[42,25.144,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.273,"y":0},"i":{"x":0.33,"y":1},"s":[960,960,0],"t":11.83,"ti":[140.667,20.667,0],"to":[-94.667,-86.667,0]},{"s":[446.624,934.727,0],"t":53.334}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[-0.829,-3.697],[0,0],[3.698,-0.828],[0,0],[0.83,3.697],[0,0],[-3.697,0.828],[0,0]],"o":[[0,0],[0.829,3.697],[0,0],[-3.697,0.828],[0,0],[-0.828,-3.697],[0,0],[3.697,-0.828]],"v":[[36.343,-18.872],[40.921,1.552],[35.727,9.747],[-28.148,24.066],[-36.344,18.872],[-40.922,-1.553],[-35.728,-9.747],[28.148,-24.066]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[0.3137,0.8157,0.3608]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[42,25.144]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":8},{"ty":4,"nm":"Element 3","sr":1,"st":9,"op":1809,"ip":9,"ln":"322","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[47.326,47.325,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.174,"y":0},"i":{"x":0.33,"y":1},"s":[959.693,959.692,0],"t":11.83,"ti":[119.667,22.5,0],"to":[-113.667,-146,0]},{"s":[529.471,736.071,0],"t":47.707}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[-11.644,-3.948],[0,0],[-3.913,2.92],[0,0],[0.157,-12.294],[0,0],[-3.986,-2.819],[0,0],[11.74,-3.65],[0,0],[1.449,-4.662],[0,0],[7.099,10.038],[0,0],[4.881,-0.063],[0,0],[-7.353,9.854],[0,0],[1.568,4.624],[0,0]],"o":[[0,0],[4.623,1.568],[0,0],[9.853,-7.353],[0,0],[-0.063,4.882],[0,0],[10.038,7.099],[0,0],[-4.662,1.449],[0,0],[-3.649,11.74],[0,0],[-2.819,-3.986],[0,0],[-12.293,0.157],[0,0],[2.92,-3.912],[0,0],[-3.948,-11.644]],"v":[[-17.558,-36.37],[-13.481,-34.987],[0.154,-37.147],[3.605,-39.722],[27.31,-27.644],[27.255,-23.339],[33.521,-11.038],[37.037,-8.552],[32.875,17.724],[28.763,19.002],[19.003,28.764],[17.724,32.876],[-8.552,37.037],[-11.039,33.522],[-23.339,27.255],[-27.645,27.309],[-39.722,3.605],[-37.146,0.154],[-34.987,-13.481],[-36.37,-17.558]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[0.2745,0.5137,0.9608]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[47.325,47.325]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":9},{"ty":4,"nm":"Element 2","sr":1,"st":9,"op":1809,"ip":9,"ln":"321","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[33.515,41.664,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.292,"y":0},"i":{"x":0.33,"y":1},"s":[960,960,0],"t":11.83,"ti":[3.667,95,0],"to":[-95.667,-127,0]},{"s":[788.638,547.457,0],"t":54.746}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.72,"y":0},"i":{"x":0.833,"y":0.994},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[3.281,-1.895],[0,0],[1.895,3.281],[0,0],[-3.281,1.894],[0,0],[-1.895,-3.281],[0,0]],"o":[[0,0],[-3.282,1.895],[0,0],[-1.895,-3.281],[0,0],[3.281,-1.894],[0,0],[1.895,3.282]],"v":[[28.859,29.054],[10.733,39.519],[1.36,37.008],[-31.37,-19.683],[-28.859,-29.054],[-10.732,-39.52],[-1.36,-37.009],[31.37,19.682]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[0.2745,0.5137,0.9608]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[33.515,41.664]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":10},{"ty":4,"nm":"Element 1","sr":1,"st":9,"op":1809,"ip":9,"ln":"320","hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[30.771,30.771,0]},"s":{"a":0,"k":[136,136,136]},"p":{"a":1,"k":[{"o":{"x":0.252,"y":0},"i":{"x":0.33,"y":1},"s":[960,960,0],"t":11.83,"ti":[40,75.667,0],"to":[22,-151.667,0]},{"s":[1027.199,504.507,0],"t":51.929}]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":1,"k":[{"o":{"x":0.167,"y":0.002},"i":{"x":0.833,"y":0.998},"s":[0],"t":4.793},{"o":{"x":0.167,"y":0},"i":{"x":0.833,"y":1},"s":[100],"t":7.607},{"o":{"x":0.167,"y":0.003},"i":{"x":0.833,"y":0.997},"s":[100],"t":27.305},{"s":[0],"t":89.999}]}},"shapes":[{"ty":"gr","nm":"Group 1","it":[{"ty":"sh","nm":"路径 1","d":1,"ks":{"a":0,"k":{"c":true,"i":[[0,-16.856],[16.856,0],[0,16.857],[-16.857,0]],"o":[[0,16.857],[-16.857,0],[0,-16.856],[16.856,0]],"v":[[30.522,-0.001],[0,30.522],[-30.522,-0.001],[0,-30.522]]}}},{"ty":"fl","nm":"Fill 1","c":{"a":0,"k":[0.255,0.686,0.502]},"r":1,"o":{"a":0,"k":100}},{"ty":"tr","a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[30.771,30.772]},"r":{"a":0,"k":0},"sa":{"a":0,"k":0},"o":{"a":0,"k":100}}]}],"ind":11}]}]} \ No newline at end of file diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..ad322bc --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..21ba165 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,7 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" + +// Local.xcconfig is still honoured for any machine-local build settings. +// It no longer carries a maps key: the app uses OpenStreetMap tiles and OSRM +// routing, neither of which takes one. +#include? "Local.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..261dd16 --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,7 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" + +// Local.xcconfig is still honoured for any machine-local build settings. +// It no longer carries a maps key: the app uses OpenStreetMap tiles and OSRM +// routing, neither of which takes one. +#include? "Local.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..6649374 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +platform :ios, '15.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..71eae79 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,66 @@ +PODS: + - Flutter (1.0.0) + - flutter_foreground_task (0.0.1): + - Flutter + - flutter_tts (0.0.1): + - Flutter + - Google-Maps-iOS-Utils (6.1.0): + - GoogleMaps (~> 9.0) + - google_maps_flutter_ios (0.0.1): + - Flutter + - Google-Maps-iOS-Utils (< 7.0, >= 5.0) + - GoogleMaps (< 10.0, >= 8.4) + - GoogleMaps (9.4.0): + - GoogleMaps/Maps (= 9.4.0) + - GoogleMaps/Maps (9.4.0) + - permission_handler_apple (9.3.0): + - Flutter + - sms_autofill (0.0.1): + - Flutter + - vibration (3.0.0): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`) + - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`) + - google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - sms_autofill (from `.symlinks/plugins/sms_autofill/ios`) + - vibration (from `.symlinks/plugins/vibration/ios`) + +SPEC REPOS: + trunk: + - Google-Maps-iOS-Utils + - GoogleMaps + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + flutter_foreground_task: + :path: ".symlinks/plugins/flutter_foreground_task/ios" + flutter_tts: + :path: ".symlinks/plugins/flutter_tts/ios" + google_maps_flutter_ios: + :path: ".symlinks/plugins/google_maps_flutter_ios/ios" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + sms_autofill: + :path: ".symlinks/plugins/sms_autofill/ios" + vibration: + :path: ".symlinks/plugins/vibration/ios" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89 + flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833 + Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96 + google_maps_flutter_ios: 0291eb2aa252298a769b04d075e4a9d747ff7264 + GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438 + permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d + sms_autofill: b36b2147535657fea83d7f3898d7831de70bd8e4 + vibration: 69774ad57825b11c951ee4c46155f455d7a592ce + +PODFILE CHECKSUM: 53a6aebc29ccee84c41f92f409fc20cd4ca011f1 + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..c6e3dbd --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,775 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3E63DC3346239265FE058C78 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A89DA7DE6BAF8CBA93BE1DC7 /* Pods_RunnerTests.framework */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + E3A7EF2DB009A8F6F600CC14 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6545460B23DF607609EA4E96 /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 00C3EEA1214E32C1603158DA /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 0CFCFAAE4D237A16096A68F7 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3295A8C1CEF37DB46CEC1553 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 371262ED233BBA0CCC66EC61 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4F8735750CC6E0280B8A7193 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 59C52FAE604D98FEAF9289FB /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 6545460B23DF607609EA4E96 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A89DA7DE6BAF8CBA93BE1DC7 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 145A88630F9206DC2C9C0BEB /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3E63DC3346239265FE058C78 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + E3A7EF2DB009A8F6F600CC14 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 455374F6F06A7AFA0827DB6B /* Frameworks */ = { + isa = PBXGroup; + children = ( + 6545460B23DF607609EA4E96 /* Pods_Runner.framework */, + A89DA7DE6BAF8CBA93BE1DC7 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + C4B42B65B7C6858559C978B0 /* Pods */, + 455374F6F06A7AFA0827DB6B /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + C4B42B65B7C6858559C978B0 /* Pods */ = { + isa = PBXGroup; + children = ( + 00C3EEA1214E32C1603158DA /* Pods-Runner.debug.xcconfig */, + 371262ED233BBA0CCC66EC61 /* Pods-Runner.release.xcconfig */, + 3295A8C1CEF37DB46CEC1553 /* Pods-Runner.profile.xcconfig */, + 4F8735750CC6E0280B8A7193 /* Pods-RunnerTests.debug.xcconfig */, + 59C52FAE604D98FEAF9289FB /* Pods-RunnerTests.release.xcconfig */, + 0CFCFAAE4D237A16096A68F7 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + E95AA65D10921A00FB59CE3F /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 145A88630F9206DC2C9C0BEB /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 97A49E827FE1B6F61363CF56 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 71EEA330115A4C86A584294A /* [CP] Embed Pods Frameworks */, + E0FDD39E7E65C7EFDE86E5B8 /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 71EEA330115A4C86A584294A /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + 97A49E827FE1B6F61363CF56 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + E0FDD39E7E65C7EFDE86E5B8 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + E95AA65D10921A00FB59CE3F /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4F8735750CC6E0280B8A7193 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 59C52FAE604D98FEAF9289FB /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0CFCFAAE4D237A16096A68F7 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..c4b79bd --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..fc6bf80 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..af0309c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..09c816f --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,131 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1", + "version" : "11.3.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", + "version" : "12.14.0" + } + }, + { + "identity" : "flutterfire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/flutterfire", + "state" : { + "revision" : "1fc2adfbeb6e132e49ba72d04c727227f9827b3e", + "version" : "4.2.1-firebase-core-swift" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", + "version" : "3.6.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", + "version" : "12.14.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", + "version" : "8.1.1" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", + "version" : "2.30910.1" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..c3fedb2 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..fc6bf80 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..af0309c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..09c816f --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,131 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1", + "version" : "11.3.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", + "version" : "12.14.0" + } + }, + { + "identity" : "flutterfire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/flutterfire", + "state" : { + "revision" : "1fc2adfbeb6e132e49ba72d04c727227f9827b3e", + "version" : "4.2.1-firebase-core-swift" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", + "version" : "3.6.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", + "version" : "12.14.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", + "version" : "8.1.1" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", + "version" : "2.30910.1" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + } + ], + "version" : 2 +} diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..2f551ff --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,18 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + // `import GoogleMaps` and a `GMSServices.provideAPIKey` call used to sit + // here, reading the key from Info.plist. The app no longer uses the Google + // Maps SDK at all — tiles come from OpenStreetMap and routes from OSRM, both + // keyless, drawn by `flutter_map` inside the Flutter view. The pod is gone, + // so this file must not reference it or the build fails to link. + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d0d98aa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..679455f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..eb64b4d Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..e0b9c32 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..b68dc73 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..e00820f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..a0ef890 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..8f92d1c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..e0b9c32 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..8b95e25 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0a8d6ad Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..be12c5b Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..38d8a9e Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..4f0a120 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..9165490 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0a8d6ad Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e429636 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..eb24f9f Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..72d94bd Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..9483bcb Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..35ad5b1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..69e5811 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json new file mode 100644 index 0000000..9f447e1 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "background.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png new file mode 100644 index 0000000..8e21404 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..00cabce --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "LaunchImage.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "LaunchImage@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "LaunchImage@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..0d0edc0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..3511a9e Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..2af1d3f Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..65a94b5 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..8d2b7d5 --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..bbb83ca --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..73aadd0 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,59 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Doormile + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + doormile + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + UIStatusBarHidden + + UIViewControllerBasedStatusBarAppearance + + NSLocationWhenInUseUsageDescription + Miler needs your location to show nearby pickups and navigate to pickup/dropoff points. + NSLocationAlwaysUsageDescription + Miler needs location access in the background to track your active pickups and update arrival times. + NSLocationAlwaysAndWhenInUseUsageDescription + Miler needs your location to show nearby pickups, navigate routes, and track active pickups in the background. + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..fae207f --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..4d206de --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/Models/login/login.dart b/lib/Models/login/login.dart new file mode 100644 index 0000000..f497ded --- /dev/null +++ b/lib/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? pickupRadius; + 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.pickupRadius, + 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'], + pickupRadius: nested['pickupRadius'], + 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/Models/riders/riders_models.dart b/lib/Models/riders/riders_models.dart new file mode 100644 index 0000000..4817bba --- /dev/null +++ b/lib/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 pickup, "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/Models/stop_status.dart b/lib/Models/stop_status.dart new file mode 100644 index 0000000..139e5cb --- /dev/null +++ b/lib/Models/stop_status.dart @@ -0,0 +1,113 @@ +/// Canonical lifecycle status for a booking/stop, parsed from the backend's +/// free-form `orderstatus` string. +/// +/// Status used to be compared as raw string literals in ~90 places, with +/// case-sensitivity landmines (`'active'` vs `'ACTIVE'`) and variant spellings +/// (`'picked'` / `'picked up'` / `'pickuped'`). This normalizes all of that in +/// one place so filters agree everywhere. +/// +/// NOTE: this is the *read* side. The status-change API still sends its own +/// protocol verbs (`ACCEPTED`/`ARRIVED`/`PICKED`/`REJECTED`/`CANCELLED`) — see +/// [OrderAction] for those canonical write values. +enum StopStatus { + newStop, + assigned, + accepted, + active, + arrived, + picked, + skipped, + cancelled, + rejected, + unknown, +} + +/// Normalizes any raw `orderstatus` value (case-insensitive, trimmed, variant +/// spellings folded) into a [StopStatus]. +StopStatus stopStatusFromRaw(dynamic raw) { + final s = (raw?.toString() ?? '').trim().toLowerCase(); + switch (s) { + case 'new': + return StopStatus.newStop; + case 'assigned': + case 'miler_assigned': + case 'pending': + return StopStatus.assigned; + case 'accepted': + return StopStatus.accepted; + case 'active': + return StopStatus.active; + case 'arrived': + return StopStatus.arrived; + case 'picked': + case 'picked up': + case 'pickuped': + case 'pickedup': + return StopStatus.picked; + case 'skipped': + return StopStatus.skipped; + case 'cancelled': + case 'canceled': + return StopStatus.cancelled; + case 'rejected': + return StopStatus.rejected; + default: + return StopStatus.unknown; + } +} + +/// Parse straight from a booking map's `orderstatus`. +StopStatus stopStatusOf(Map booking) => + stopStatusFromRaw(booking['orderstatus']); + +extension StopStatusX on StopStatus { + bool get isPicked => this == StopStatus.picked; + bool get isCancelled => this == StopStatus.cancelled; + bool get isSkipped => this == StopStatus.skipped; + 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; + + /// Still awaiting the rider's acceptance — belongs on Home, not Bookings. + bool get isPending => + this == StopStatus.newStop || this == StopStatus.assigned; + + /// What this state is *called*, in the rider's words. + /// + /// The status vocabulary had been re-invented per widget: the live banner had + /// its own switch over raw `orderstatus` strings returning "At pickup" and + /// "In progress", the trip card wrote 'Accepted' as a literal, and the two + /// disagreed about the same backend state. A rider moving between Home, the + /// banner and Bookings was reading three vocabularies for one lifecycle and + /// re-learning the app at each stop. + /// + /// One list, here, next to the parser that produces the states — so a new + /// status cannot be added without someone deciding what to call it. + String get label => switch (this) { + StopStatus.newStop => 'New', + StopStatus.assigned => 'Assigned', + StopStatus.accepted => 'Accepted', + StopStatus.active => 'In progress', + StopStatus.arrived => 'At the stop', + StopStatus.picked => 'Completed', + StopStatus.skipped => 'Skipped', + StopStatus.cancelled => 'Cancelled', + StopStatus.rejected => 'Rejected', + StopStatus.unknown => 'Active', + }; +} + +/// Canonical protocol verbs sent to the status-change API (the *write* side). +class OrderAction { + static const String accept = 'ACCEPT'; + static const String accepted = 'ACCEPTED'; + static const String arrived = 'ARRIVED'; + static const String picked = 'PICKED'; + static const String rejected = 'REJECTED'; + static const String cancelled = 'CANCELLED'; +} diff --git a/lib/Models/summary/pickupstats.dart b/lib/Models/summary/pickupstats.dart new file mode 100644 index 0000000..972caa6 --- /dev/null +++ b/lib/Models/summary/pickupstats.dart @@ -0,0 +1,25 @@ +class PickupStats { + final int today; + final int week; + final int month; + final int total; + final int cancelled; + + PickupStats({ + required this.today, + required this.week, + required this.month, + required this.total, + required this.cancelled, + }); + + factory PickupStats.fromJson(Map json) { + return PickupStats( + today: json['today'] ?? 0, + week: json['week'] ?? 0, + month: json['month'] ?? 0, + total: json['total'] ?? 0, + cancelled: json['cancelled'] ?? 0, + ); + } +} diff --git a/lib/Models/summary/riderweeklykms.dart b/lib/Models/summary/riderweeklykms.dart new file mode 100644 index 0000000..1e7754e --- /dev/null +++ b/lib/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: (json['kms'] ?? 0).toDouble(), + ); + } +} + +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: (json['total_kms'] ?? 0).toDouble(), + overallKms: (json['overall_kms'] ?? 0).toDouble(), + details: (json['details'] as List? ?? []) + .map((e) => RiderWeeklyKms.fromJson(e)) + .toList(), + ); + } +} diff --git a/lib/Models/supportticket/support_ticket.dart b/lib/Models/supportticket/support_ticket.dart new file mode 100644 index 0000000..8d0265e --- /dev/null +++ b/lib/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/background/backgroundservice.dart b/lib/background/backgroundservice.dart new file mode 100644 index 0000000..6edf304 --- /dev/null +++ b/lib/background/backgroundservice.dart @@ -0,0 +1,981 @@ +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/providers/pickuplog/pickuplog_provider.dart'; +import 'package:miler/providers/notifications/notificationservce.dart'; +import 'package:miler/views/helpers/constants/apiconstants.dart'; +import 'package:miler/utils/kalman_filter.dart'; + +/// Background service for managing active pickup logs +class BackgroundCollectionLog { + static MilerKalmanFilter? _kf; + static DateTime? _lastUpdateTime; + + static final CreatePickupLogProvider _logProvider = CreatePickupLogProvider(); + 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 _activePickupsKey = 'active_pickup'; + static const String _payloadKeyPrefix = 'pickup_payload_'; + static const String _offlineLogKey = 'offline_pickup_logs'; + static const String _proximityAlertKeyPrefix = 'pickup_proximity_alerted_'; + + static bool _isPosting = false; + static bool _ttsReady = false; + static bool _audioReady = false; + + /// Process active Pickups: fetch from API, filter active, and post logs + static Future processActivePickups() async { + if (_isPosting) { + debugPrint('[ACTIVE_COLLECTION_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_COLLECTION_LOG][BG] Checking for user: $userId'); + + if (userId == 0) { + debugPrint('[ACTIVE_COLLECTION_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 (getpickup) + final bool isLive = ApiConstants.mainRoute == 'live'; + final baseUrl = isLive + ? ApiConstants.currentPickupLive + : ApiConstants.currentPickupDev; + + final uri = Uri.parse(baseUrl).replace( + queryParameters: { + 'userid': userId.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }, + ); + + debugPrint('[ACTIVE_COLLECTION_LOG][BG] Fetching Pickup: $uri'); + + // Fetch pickup from API using v1 endpoint + final pickups = await _fetchPickupsFromApi(uri); + + debugPrint( + '[ACTIVE_COLLECTION_LOG][BG] API returned ${pickups.length} items', + ); + + // Filter for active bookings only + final activeOrders = pickups.whereType>().where(( + Booking, + ) { + final status = (Booking['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final isActive = status == 'active'; + return isActive; + }).toList(); + + if (activeOrders.isEmpty) { + debugPrint('[ACTIVE_COLLECTION_LOG][BG] No active bookings found'); + // ✅ Still check shift end even if no active pickup + debugPrint('[ACTIVE_COLLECTION_LOG][BG] 🔍 Checking shift end time...'); + await checkShiftEnd(); + return; + } + + debugPrint( + '[ACTIVE_COLLECTION_LOG][BG] Found ${activeOrders.length} active Pickup', + ); + + // Post logs for each active pickup + for (final Booking in activeOrders) { + final orderId = (Booking['orderid'] ?? '').toString(); + if (orderId.isEmpty) continue; + + // Get current coordinates once per Booking to reuse for proximity + log + final Map? coords = await _getCoordinatesWithFallback(); + if (coords != null) { + await _checkProximityAlert(Booking, coords); + } + + // Get payload from SharedPreferences or create new one + Map? payload = await _loadPayload(orderId); + if (payload == null) { + // Create new payload from Booking data with userId from SharedPreferences + payload = _createPayload(Booking, userId); + await _persistPayload(orderId, payload); + await _markAsActive(orderId); + } else { + // Ensure userid is set correctly in existing payload + payload['userid'] = userId; + } + + // Post the log + await _postPickupLog(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_COLLECTION_LOG][BG] Error processing: $e'); + } finally { + _isPosting = false; + } + } + + /// Post pickup log for a specific Booking + static Future _postPickupLog( + 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 pickupId = payload['pickupid']?.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 (pickupId.isNotEmpty) { + try { + final prefs = await SharedPreferences.getInstance(); + riderKmsToSend = + double.tryParse( + prefs.getString('pickup_tracking_${pickupId}_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.createPickupLogLive + : ApiConstants.createPickupLogDev; + + // Post the log + debugPrint('[ACTIVE_COLLECTION_LOG][BG] Posting pickup log for $orderId'); + + final result = await _logProvider + .createPickupLog(payloadWithCoords) + .timeout( + const Duration(seconds: 8), + onTimeout: () => + throw TimeoutException('Pickup log post timeout for $orderId'), + ); + + if (result != null) { + debugPrint('[ACTIVE_COLLECTION_LOG][BG] Success for $orderId'); + await _persistLastLogLocation(orderId, coords); + } else { + debugPrint( + '[ACTIVE_COLLECTION_LOG][BG] Warning: No response for $orderId', + ); + } + } catch (e) { + debugPrint( + '[ACTIVE_COLLECTION_LOG][BG] Failed to post log for $orderId: $e', + ); + // Offline fallback + if (payloadWithCoords != null) { + await _saveToOfflineQueue(orderId, payloadWithCoords); + } + } + } + + /// Create payload from pickup Booking data + static Map _createPayload( + Map pickup, + int userId, + ) { + return { + 'logid': 0, + 'tenantid': pickup['tenantid'] ?? 0, + 'partnerid': pickup['partnerid'] ?? 0, + 'locationid': pickup['locationid'] ?? 0, + 'orderheaderid': pickup['orderheaderid'] ?? 0, + 'pickupid': pickup['pickupid'] ?? 0, + 'userid': userId, + 'orderid': (pickup['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_COLLECTION_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_COLLECTION_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_COLLECTION_LOG][BG] Failed to get current pos: $e'); + position = await Geolocator.getLastKnownPosition(); + } + + if (position != null) { + 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 = MilerKalmanFilter(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_COLLECTION_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 Booking as active in SharedPreferences + static Future _markAsActive(String orderId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final list = prefs.getStringList(_activePickupsKey) ?? []; + if (!list.contains(orderId)) { + list.add(orderId); + await prefs.setStringList(_activePickupsKey, list); + } + } catch (_) {} + } + + /// Fetch pickup from API using v1 endpoint + static Future> _fetchPickupsFromApi(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_COLLECTION_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_COLLECTION_LOG][BG][OFFLINE] Flushing ${queue.length} offline logs...', + ); + + final List remaining = []; + bool anySuccess = false; + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createPickupLogLive + : ApiConstants.createPickupLogDev; + + 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_COLLECTION_LOG][BG][OFFLINE] Retrying for orderId: $orderId', + ); + + final result = await _logProvider + .createPickupLog(payload) + .timeout(const Duration(seconds: 8)); + + if (result != null) { + debugPrint( + '[ACTIVE_COLLECTION_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_COLLECTION_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_PICKUP_LOG][BG][OFFLINE] Saved to queue. Total: ${queue.length}', + ); + } catch (e) { + debugPrint('[ACTIVE_PICKUP_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 pickup 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 pickup when within threshold + static Future _checkProximityAlert( + Map Booking, + Map coords, + ) async { + try { + final pickupId = (Booking['pickupid'] ?? Booking['orderid'] ?? '') + .toString(); + if (pickupId.isEmpty) return; + + final dropLatRaw = + (Booking['droplat'] ?? + Booking['DropLat'] ?? + Booking['pickuplat'] ?? + '') + .toString(); + final dropLngRaw = + (Booking['droplon'] ?? + Booking['droplong'] ?? + Booking['DropLon'] ?? + Booking['DropLong'] ?? + Booking['pickuplong'] ?? + '') + .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) { + await NotificationServce.showLocalNotification( + title: 'Near pickup location', + body: 'You’ve reached your destination. Please update the status.', + ); + + final played = await _playProximityAudio(); + + debugPrint( + '[PROXIMITY] Alerted pickupId=$pickupId at ${distanceMeters.toStringAsFixed(1)}m ' + 'target=($dropLat,$dropLng) rider=($riderLat,$riderLng) played=$played', + ); + } else { + debugPrint( + '[PROXIMITY] Skipped alert for pickupId=$pickupId | 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 pickup status.', + ); + debugPrint('[PROXIMITY][TTS] Spoke destination prompt'); + return true; + } catch (e) { + debugPrint('[PROXIMITY][TTS] Error speaking prompt: $e'); + } + return false; + } +} diff --git a/lib/background/foreground_service.dart b/lib/background/foreground_service.dart new file mode 100644 index 0000000..19501a5 --- /dev/null +++ b/lib/background/foreground_service.dart @@ -0,0 +1,345 @@ +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/views/helpers/constants/apiconstants.dart'; +import 'package:miler/providers/Riderlog/riderlog_provider.dart'; +import 'package:miler/background/backgroundservice.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:miler/utils/kalman_filter.dart'; +import 'package:miler/utils/mqtt_service.dart'; +import 'package:miler/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 MilerKalmanFilter? _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, + ), + ); + 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 = MilerKalmanFilter(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"; + } + + static Future createLoginNow() async { + try { + final prefs = await SharedPreferences.getInstance(); + 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 pickup to set status + final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; + final String riderStatus = hasActivePickups ? 'active' : 'idle'; + + final now = DateTime.now(); + final iso = _formatDateTimeFull(now); + final loginTime = _formatTime(now); + final loc = await _ensureLatLng(); + + 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(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); + + // ✅ MQTT BACKGROUND PUBLISH ( Lane Split ) + final mqttService = MilerMqttService(); + 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. Pickup Log (new) + await BackgroundCollectionLog.processActivePickups(); + + // 3. Auto Shift End (new) + await BackgroundCollectionLog.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/background/live_tracking_service.dart b/lib/background/live_tracking_service.dart new file mode 100644 index 0000000..d037210 --- /dev/null +++ b/lib/background/live_tracking_service.dart @@ -0,0 +1,246 @@ +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/utils/kalman_filter.dart'; +import 'package:miler/utils/mqtt_service.dart'; +import 'package:battery_plus/battery_plus.dart'; + +class LiveTrackingService { + static final LiveTrackingService _instance = LiveTrackingService._internal(); + + factory LiveTrackingService() { + return _instance; + } + + LiveTrackingService._internal(); + + StreamSubscription? _positionStreamSubscription; + bool _isTracking = false; + MilerKalmanFilter? _kf; + DateTime? _lastUpdateTime; + + void startTracking() async { + if (_isTracking) return; + + // Check permissions before starting + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint('[LIVE TRACKING] Location services are disabled.'); + return; + } + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + debugPrint('[LIVE TRACKING] Location permission denied.'); + return; + } + + _isTracking = true; + debugPrint('[LIVE TRACKING] Started high-frequency live tracking...'); + + final LocationSettings locationSettings = const LocationSettings( + accuracy: LocationAccuracy + .bestForNavigation, // High precision required for Kalman + distanceFilter: 0, // Request updates regardless of distance moved + ); + + // Some Android devices might drop points if we don't have a foreground service, + // but the app already uses flutter_foreground_task elsewhere. + _positionStreamSubscription = + Geolocator.getPositionStream( + locationSettings: locationSettings, + ).listen((Position? position) async { + if (position != null) { + // Implement your own custom debounce if it happens too fast, e.g limit to 2-3 secs + _sendToKalmanBackend(position); + } + }); + + _positionStreamSubscription?.onError((error) { + debugPrint('[LIVE TRACKING] Stream error: $error'); + }); + } + + void stopTracking() { + if (!_isTracking) return; + debugPrint('[LIVE TRACKING] Stopped live tracking.'); + _positionStreamSubscription?.cancel(); + _positionStreamSubscription = null; + _kf = null; // Reset filter state + _lastUpdateTime = null; + _isTracking = false; + } + + // Rate limiter details + DateTime? _lastSentTime; + + Future _sendToKalmanBackend(Position position) async { + final now = DateTime.now(); + // Only send if it has been at least 3 seconds + if (_lastSentTime != null && now.difference(_lastSentTime!).inSeconds < 3) { + return; + } + _lastSentTime = now; + + try { + final prefs = await SharedPreferences.getInstance(); + final userid = prefs.getInt('userId') ?? prefs.getInt('userid'); + final currentOrderId = prefs.getString('current_riding_order_id') ?? ''; + + // Decompose velocity for logging/backend reference + 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); + + // --- APPLY LOCAL KALMAN FILTER --- + double displayLat = position.latitude; + double displayLng = position.longitude; + + if (_kf == null) { + // Initialize filter with first measurement + _kf = MilerKalmanFilter( + lat: position.latitude, + lng: position.longitude, + ); + } else { + // Calculate dt (delta time) since last update + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : 3.0; // Default to 3s if unknown + + _kf!.predict(dt); + _kf!.update(position.latitude, position.longitude); + + // Extract smoothed coordinates from state vector [lat, lng, v_lat, v_lng] + displayLat = _kf!.x[0]; + displayLng = _kf!.x[1]; + } + _lastUpdateTime = now; + + final payload = { + 'userid': userid, + 'orderid': currentOrderId, + 'lat': displayLat, // Smoothed Latitude + 'lng': displayLng, // Smoothed Longitude + 'raw_lat': + position.latitude, // Kept for server-side verification if needed + 'raw_lng': position.longitude, + 'speed': position.speed, + 'heading': position.heading, + 'velocity_lat': velocityLat, + 'velocity_lng': velocityLng, + 'timestamp': now.toIso8601String(), + }; + + // --- TRACK ACTIVE PICKUPS DISTANCE --- + final activePickupIds = + prefs.getStringList('active_tracking_pickup_ids') ?? []; + + for (String dId in activePickupIds) { + try { + final lastLatStr = + prefs.getString('pickup_tracking_${dId}_lastLat') ?? ''; + final lastLngStr = + prefs.getString('pickup_tracking_${dId}_lastLng') ?? ''; + final currentCumulativeKm = + double.tryParse( + prefs.getString('pickup_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, + ); + + if (distanceMeters >= 5.0) { + // minimum 5 meters to reduce drift accumulation + final distanceKm = distanceMeters / 1000.0; + final newCumulativeKm = currentCumulativeKm + distanceKm; + + await prefs.setString( + 'pickup_tracking_${dId}_cumulativeKm', + newCumulativeKm.toStringAsFixed(4), + ); + await prefs.setString( + 'pickup_tracking_${dId}_lastLat', + displayLat.toString(), + ); + await prefs.setString( + 'pickup_tracking_${dId}_lastLng', + displayLng.toString(), + ); + } + } + } else { + await prefs.setString( + 'pickup_tracking_${dId}_lastLat', + displayLat.toString(), + ); + await prefs.setString( + 'pickup_tracking_${dId}_lastLng', + displayLng.toString(), + ); + } + } catch (_) {} + } + + // ------------------------------------------------------------- + // OPTION 1: MQTT PUBLICATION (Integrated) + // ------------------------------------------------------------- + final mqttService = MilerMqttService(); + if (mqttService.isConnected) { + mqttService.publishLocation(payload); + debugPrint( + '[LIVE TRACKING] Dispatched to MQTT: ${payload['lat']}, ${payload['lng']}', + ); + } else { + // Fallback or attempt reconnect if on-duty + debugPrint( + '[LIVE TRACKING] MQTT not connected. Skipping MQTT dispatch.', + ); + mqttService.connect(); // Attempt to restore connection + } + + // ------------------------------------------------------------- + // OPTION 2: HTTP POST PLACEHOLDER (Wait for further instruction) + // ------------------------------------------------------------- + // final url = Uri.parse('https://your-python-backend.com/kalman/track'); + // http.post(url, headers: {'Content-Type': 'application/json'}, body: jsonEncode(payload)); + + // ------------------------------------------------------------- + // OPTION 3: TELEMETRY (Battery/System logs) + // ------------------------------------------------------------- + if (now.minute % 5 == 0 && now.second < 5) { + // Log every 5 mins + final battery = Battery(); + final batteryLevel = await battery.batteryLevel; + mqttService.publishTelemetry({ + 'battery_level': batteryLevel, + 'gps_accuracy': position.accuracy, + 'mocked': position.isMocked, + 'timestamp': now.toIso8601String(), + }); + } + + debugPrint( + '[LIVE TRACKING] Kalman processed: ${payload['lat']}, ${payload['lng']} (speed: ${position.speed})', + ); + } catch (e) { + debugPrint('[LIVE TRACKING] Error sending to backend: $e'); + } + } +} diff --git a/lib/controllers/auth.dart b/lib/controllers/auth.dart new file mode 100644 index 0000000..e803a68 --- /dev/null +++ b/lib/controllers/auth.dart @@ -0,0 +1,348 @@ +import 'dart:io' show Platform; +import 'package:flutter/material.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'; +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'; + +enum AuthNext { verifyPin, otp, notRegistered, error } + +class AuthController extends GetxController { + final RxBool sendingOtp = false.obs; + String? currentPhone; + final AuthProvider _api = AuthProvider(); + AuthNext? lastDecision; + // Optional callback used by MPIN screen to clear and refocus fields when user taps "Retry" + VoidCallback? onPinRetry; + static const String _prefsUserIdKey = 'userid'; + static const String _prefsPendingPinUserIdKey = 'pending_pin_userid'; + static const String _prefsUserNameKey = 'user_name'; + static const String _prefsUserEmailKey = 'user_email'; + static const String _prefsContactNoKey = 'contactno'; + static const String _prefsAddressKey = 'user_address'; + static const String _prefsForceMasterPinKey = 'force_master_pin'; + static const String _masterPinValue = '1234'; + static const String forceMasterPinPrefKey = _prefsForceMasterPinKey; + static const String masterPinValue = _masterPinValue; + bool _forceMasterPinFlow = false; + Future _notifyProfileController() async { + try { + if (Get.isRegistered()) { + final prefs = await SharedPreferences.getInstance(); + final pc = Get.find(); + await pc.loadFromPrefs(); + pc.setProfile( + name: prefs.getString(_prefsUserNameKey), + email: prefs.getString(_prefsUserEmailKey), + contact: prefs.getString(_prefsContactNoKey), + address: prefs.getString(_prefsAddressKey), + ); + } + } catch (_) {} + } + + String _normalizePhone(String input) { + final digitsOnly = input.replaceAll(RegExp(r'\D'), ''); + if (digitsOnly.length >= 10) { + return digitsOnly.substring(digitsOnly.length - 10); + } + return digitsOnly; + } + + void _showBottomSheet({required String title, required String message}) { + 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(); + }, + ), + ], + ), + ), + ), + isScrollControlled: true, + backgroundColor: Colors.transparent, + ); + } + + Future precheckPhone(String phone) async { + try { + final normalized = _normalizePhone(phone); + currentPhone = normalized; + final prefs = await SharedPreferences.getInstance(); + + // The mocked "Demo Rider" (userid 9999) that used to be written here is + // gone. It bypassed the server entirely and left a fake identity in prefs + // that outlived the session it was created for — every screen reading + // 'userid' got 9999 until the app was reinstalled. The real user is + // established by verify-pin and nowhere else. + await prefs.setString(_prefsContactNoKey, normalized); + + // On the live backend, ask whether this phone already belongs to an + // active miler account with a PIN on file. If it does, go straight to the + // MPIN screen: OTP delivery isn't live yet, and the OTP path ends at + // 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. + final exists = await _api.milerAccountExists(normalized); + if (exists) { + lastDecision = AuthNext.verifyPin; + return lastDecision!; + } + + // Unknown number (or legacy backend) — fall through to the OTP step. + lastDecision = AuthNext.otp; + return lastDecision!; + } catch (e) { + debugPrint('Precheck phone error: $e'); + lastDecision = AuthNext.error; + return lastDecision!; + } + } + + Future sendOtp([String? phoneArg]) async { + if (sendingOtp.value) return false; + if (phoneArg != null && phoneArg.isNotEmpty) { + currentPhone = _normalizePhone(phoneArg); + } + sendingOtp.value = true; + try { + await Future.delayed(const Duration(milliseconds: 500)); + return true; + } finally { + sendingOtp.value = false; + } + } + + Future verifyOtp(String code) async { + // Automatically succeed for mocked login + return true; + } + + Future setPin(String newPin) async { + try { + final prefs = await SharedPreferences.getInstance(); + int? userId = + prefs.getInt(_prefsPendingPinUserIdKey) ?? + prefs.getInt(_prefsUserIdKey); + if (newPin.length != 4 || int.tryParse(newPin) == null) { + _showBottomSheet( + title: 'Invalid PIN', + message: 'Please enter a valid 4-digit PIN.', + ); + return false; + } + if (userId == null) { + _showBottomSheet( + title: 'Error', + message: 'User ID not found. Please try again.', + ); + 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); + if (res.statusCode >= 200 && res.statusCode < 300) { + await prefs.setString('dbPin', newPin); + await prefs.remove(_prefsPendingPinUserIdKey); + return true; + } + final bodyPreview = res.body.length > 200 + ? '${res.body.substring(0, 200)}...' + : res.body; + _showBottomSheet( + title: 'Failed (${res.statusCode})', + message: 'Unable to set PIN. Server said: $bodyPreview', + ); + return false; + } catch (e) { + debugPrint('setPin error: $e'); + _showBottomSheet( + title: 'Error', + message: 'Something went wrong while setting the PIN.', + ); + return false; + } + } + + /// Refresh session on backend with latest deviceId/FCM for the current phone. + Future refreshSession({String? phone}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final String? usePhone = phone ?? currentPhone; + if (usePhone == null || usePhone.isEmpty) { + return false; + } + final deviceId = await DeviceUtils.ensureDeviceId(prefs); + final fcmToken = await DeviceUtils.ensureFcmToken(prefs); + final Login loginRes = await _api.loginParsed( + contactNo: usePhone, + deviceType: Platform.operatingSystem, + configId: 6, + deviceId: deviceId, + fcmToken: fcmToken, + ); + if (loginRes.userid != null) { + await prefs.setInt(_prefsUserIdKey, loginRes.userid!); + } + try { + final String? name = loginRes.fullname ?? loginRes.firstname; + final String? email = loginRes.email; + final String contact = (loginRes.contactno ?? usePhone).toString(); + final String? address = loginRes.address; + if (name != null && name.trim().isNotEmpty) { + await prefs.setString(_prefsUserNameKey, name.trim()); + } + if (email != null && email.trim().isNotEmpty) { + await prefs.setString(_prefsUserEmailKey, email.trim()); + } + if (contact.isNotEmpty) { + final normalizedContact = _normalizePhone(contact); + await prefs.setString(_prefsContactNoKey, normalizedContact); + } + if (address != null && address.trim().isNotEmpty) { + await prefs.setString(_prefsAddressKey, address.trim()); + } + await _notifyProfileController(); + } catch (_) {} + currentPhone = _normalizePhone(usePhone); + return loginRes.status == true; + } catch (_) { + return false; + } + } + + Future verifyPinWithServer(String inputPin) async { + final prefs = await SharedPreferences.getInstance(); + + // The mocked-login bypass that used to sit here accepted ANY four digits + // and logged the rider in without asking the server. It is gone: a PIN + // check that cannot fail is not a PIN check. + + // Authenticate phone + PIN against POST /miler/verify-pin. Only a real + // success (server ok + bearer token stored) logs the rider in. + try { + final String phone = + 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.', + ); + return false; + } + + final deviceId = await DeviceUtils.ensureDeviceId(prefs); + final fcmToken = await DeviceUtils.ensureFcmToken(prefs); + final Login res = await _api.loginParsed( + contactNo: phone, + deviceType: Platform.operatingSystem, + configId: 6, + deviceId: deviceId, + fcmToken: fcmToken, + pin: pinNum, + pinRaw: inputPin, + ); + + final String? token = await ApiConfig.getToken(); + final bool ok = res.status == true && token != null && token.isNotEmpty; + if (ok) { + await prefs.setString('dbPin', inputPin); + await prefs.setBool('logged_out', false); + currentPhone = _normalizePhone(phone); + + // Overwrite any stale demo profile with the REAL logged-in identity. + // The UI reads the display name from 'user_name'; loginParsed only wrote + // 'username'/'firstname', so mirror the real name/email/contact here. + final String realName = + (res.fullname != null && res.fullname!.trim().isNotEmpty) + ? res.fullname!.trim() + : (prefs.getString('username') ?? + '${res.firstname ?? ''} ${res.lastname ?? ''}') + .trim(); + if (realName.isNotEmpty) { + await prefs.setString(_prefsUserNameKey, realName); + } + final String realEmail = (res.email ?? '').trim(); + if (realEmail.isNotEmpty) { + await prefs.setString(_prefsUserEmailKey, realEmail); + } + await prefs.setString(_prefsContactNoKey, _normalizePhone(phone)); + + await _notifyProfileController(); + return true; + } + + _showBottomSheet( + title: 'Login failed', + message: (res.message != null && res.message!.trim().isNotEmpty) + ? res.message! + : 'Incorrect phone number or PIN. Please try again.', + ); + return false; + } catch (e) { + debugPrint('verifyPinWithServer error: $e'); + _showBottomSheet( + title: 'Connection error', + message: 'Could not reach the server. Check your internet and retry.', + ); + return false; + } + } +} diff --git a/lib/controllers/connectivity_mixin.dart b/lib/controllers/connectivity_mixin.dart new file mode 100644 index 0000000..3ce5705 --- /dev/null +++ b/lib/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/controllers/duty_controller.dart b/lib/controllers/duty_controller.dart new file mode 100644 index 0000000..1a2600c --- /dev/null +++ b/lib/controllers/duty_controller.dart @@ -0,0 +1,66 @@ +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Single source of truth for the rider's online / on-duty state. +/// +/// Duty state used to be re-derived from raw SharedPreferences in a dozen +/// places (homepage field, pickups gate, done screen…), and the `onduty` int is +/// clobbered by a flaky server rider-log sync — which is what auto-kicked riders +/// offline after a pickup. This controller centralises the read with the +/// corrected priority so every screen agrees. +/// +/// Priority when resolving "is the rider online?": +/// 1. onduty == 1 → a fresh server shift is authoritative. +/// 2. explicit `online` → the rider's toggle; SURVIVES the flaky onduty sync +/// (every legit offline path — manual toggle, auto +/// shift-end — sets online=false, so it's trustworthy). +/// 3. onduty == 0 → offline. +/// 4. otherwise → default online. +class DutyController extends GetxController { + /// Whether the rider is online (on duty) and may receive bookings / act. + final RxBool isOnline = false.obs; + + static const String _kOnduty = 'onduty'; + static const String _kOnline = 'online'; + + /// Convenience accessor — registers the controller on first use. + static DutyController get to => Get.isRegistered() + ? Get.find() + : Get.put(DutyController(), permanent: true); + + /// True if the rider may receive bookings and start pickups/deliveries. + bool get canWork => isOnline.value; + + /// Resolves and broadcasts the current online state from persisted prefs. + Future load() async { + final prefs = await SharedPreferences.getInstance(); + final int onduty = prefs.getInt(_kOnduty) ?? -1; + final bool? savedOnline = prefs.getBool(_kOnline); + + bool online; + if (onduty == 1) { + online = true; + } else if (savedOnline != null) { + online = savedOnline; + } else if (onduty == 0) { + online = false; + } else { + online = true; + } + + // Keep the persisted bool consistent so the fallback stays correct. + await prefs.setBool(_kOnline, online); + isOnline.value = online; + return online; + } + + /// Persists + broadcasts a new online state. Call after a successful duty + /// toggle so every listener updates immediately. Going offline also clears + /// the raw onduty flag so a stale `1` can't resurrect an online state. + Future setOnlineLocal(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_kOnline, value); + if (!value) await prefs.setInt(_kOnduty, 0); + isOnline.value = value; + } +} diff --git a/lib/controllers/logcontroller.dart b/lib/controllers/logcontroller.dart new file mode 100644 index 0000000..1841c37 --- /dev/null +++ b/lib/controllers/logcontroller.dart @@ -0,0 +1,181 @@ +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/providers/pickuplog/pickuplog_provider.dart'; +import 'package:miler/views/helpers/constants/apiconstants.dart'; +import 'package:miler/background/foreground_service.dart' as fg; +import 'package:geolocator/geolocator.dart'; + +/// Controller for managing active pickup logs +/// Now delegates to the background service for actual logging +class LogController extends GetxController { + final CreatePickupLogProvider _logProvider = CreatePickupLogProvider(); + + static const String _offlineLogKey = 'offline_pickup_logs'; + bool _isFlushing = false; + + /// Start the pickup log streaming service (via foreground service) + Future startLogging() async { + debugPrint('[ACTIVE_COLLECTION_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_COLLECTION_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_COLLECTION_LOG] Foreground service already running', + ); + return; + } + + debugPrint( + '[ACTIVE_COLLECTION_LOG] Starting foreground service for collection logs', + ); + + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'doormile_bg_service', + channelName: 'Background Service', + channelDescription: + 'Keeps Miler 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_COLLECTION_LOG] Location permission missing, skipping service start', + ); + return; + } + + try { + await FlutterForegroundTask.startService( + notificationTitle: 'Miler is running', + notificationText: 'You are Currently on Duty !', + callback: fg.riderLogCallback, + ); + } catch (e) { + debugPrint('[ACTIVE_COLLECTION_LOG] Failed to start service: $e'); + } + } else { + debugPrint( + '[ACTIVE_COLLECTION_LOG] iOS/Web not fully supported for background service yet', + ); + } + } + + /// Stop the pickup 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_COLLECTION_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_COLLECTION_LOG][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 orderId = item['orderId'] ?? ''; + final Map payload = Map.from( + item['payload'] ?? {}, + ); + + if (payload.isEmpty) continue; + + debugPrint( + '[ACTIVE_COLLECTION_LOG][OFFLINE] Retrying for orderId: $orderId', + ); + + final result = await _logProvider + .createPickupLog(payload) + .timeout(const Duration(seconds: 8)); + + if (result != null) { + debugPrint( + '[ACTIVE_COLLECTION_LOG][OFFLINE] Success for orderId: $orderId', + ); + anySuccess = true; + } else { + remaining.add(itemStr); + } + } catch (e) { + debugPrint( + '[ACTIVE_COLLECTION_LOG][OFFLINE] Error processing item: $e', + ); + remaining.add(itemStr); + } + } + + if (anySuccess || remaining.length != queue.length) { + await prefs.setStringList(_offlineLogKey, remaining); + debugPrint( + '[ACTIVE_COLLECTION_LOG][OFFLINE] Flush complete. Remaining: ${remaining.length}', + ); + } + } catch (e) { + debugPrint('[ACTIVE_COLLECTION_LOG][OFFLINE] Flush error: $e'); + } finally { + _isFlushing = false; + } + } +} diff --git a/lib/controllers/pickup.dart b/lib/controllers/pickup.dart new file mode 100644 index 0000000..59d2f78 --- /dev/null +++ b/lib/controllers/pickup.dart @@ -0,0 +1,123 @@ +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class PickupController 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 pickupId = 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 = 'pickup_tenantid'; + static const String kPartnerId = 'pickup_partnerid'; + static const String kLocationId = 'pickup_locationid'; + static const String kOrderHeaderId = 'pickup_orderheaderid'; + static const String kPickupId = 'pickup_pickupid'; + static const String kUserId = 'pickup_userid'; + static const String kOrderId = 'pickup_orderid'; + static const String kOrderStatus = 'pickup_orderstatus'; + static const String kLat = 'pickup_latitude'; + static const String kLng = 'pickup_longitude'; + + Future saveFromQueueItem(Map pickup) async { + final prefs = await SharedPreferences.getInstance(); + + final int? tenantid = pickup['tenantid'] is int + ? pickup['tenantid'] as int + : int.tryParse('${pickup['tenantid'] ?? ''}'); + final int? partnerid = pickup['partnerid'] is int + ? pickup['partnerid'] as int + : int.tryParse('${pickup['partnerid'] ?? ''}'); + final int? locationid = pickup['locationid'] is int + ? pickup['locationid'] as int + : int.tryParse('${pickup['locationid'] ?? ''}'); + final int? orderheaderid = pickup['orderheaderid'] is int + ? pickup['orderheaderid'] as int + : int.tryParse('${pickup['orderheaderid'] ?? ''}'); + final int? pickupid = pickup['pickupid'] is int + ? pickup['pickupid'] as int + : int.tryParse('${pickup['pickupid'] ?? ''}'); + final int? userid = pickup['userid'] is int + ? pickup['userid'] as int + : int.tryParse('${pickup['userid'] ?? ''}'); + + final String oid = (pickup['orderid'] ?? '').toString(); + final String ostatus = (pickup['orderstatus'] ?? '').toString(); + + // Prefer pickuplat/long, fallback to droplat/lon + final String lat = (pickup['pickuplat'] ?? pickup['droplat'] ?? '') + .toString(); + final String lon = (pickup['pickuplong'] ?? pickup['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 (pickupid != null) await prefs.setInt(kPickupId, pickupid); + 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; + pickupId.value = pickupid ?? 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; + pickupId.value = prefs.getInt(kPickupId) ?? 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(kPickupId); + 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; + pickupId.value = 0; + userId.value = 0; + latitude.value = ''; + longitude.value = ''; + } +} diff --git a/lib/controllers/pickups_controller.dart b/lib/controllers/pickups_controller.dart new file mode 100644 index 0000000..3bfd361 --- /dev/null +++ b/lib/controllers/pickups_controller.dart @@ -0,0 +1,1981 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart' show LatLng; +import 'package:miler/helpers/miler_router.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:geolocator/geolocator.dart'; + +import 'package:miler/views/helpers/constants/apiconstants.dart'; +import 'package:miler/providers/pickuplog/pickuplog_provider.dart'; +import 'dart:io'; +import 'package:minio/minio.dart'; +import 'package:minio/io.dart'; +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/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. +/// +/// 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; + +class PickupsController extends GetxController + with ConnectivityControllerMixin { + MilerKalmanFilter? _kf; + DateTime? _lastUpdateTime; + + final UpdatePickupProvider _updateProvider = UpdatePickupProvider(); + + 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 pickedShimmer = false.obs; + final RxBool isPipEnabled = false.obs; + + // Distance tracking for PiP mode + final RxDouble pickupableDistance = 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 _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; + + // ── Compliance of the stop just completed ── + // + // All three are computed here already — the bonus rule is literally "did he + // arrive before the ETA deadline", and the distance is measured to bill + // rider charges — but they were only ever written to the payload and the log. + // The rider could not see whether he had made his ETA or how far off the + // planned route he had gone, and neither could the Activity tab. + // + // Published so the completion path can stamp them onto the finished stop. + // Null means "not knowable for this stop" (no ETA was set, no distance could + // be measured) — which the UI states as unknown rather than as a failure. + final Rxn lastOnTime = Rxn(); + final Rxn lastLateBy = Rxn(); + final RxDouble lastRiderKms = 0.0.obs; + + // Trigger to refresh pickup 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 pickupId, + ) async { + 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" + + // 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-$pickupId-$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 = "https://images.doormile.app/$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"); + AppFeedback.errorGlobal('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 pickup + 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 = MilerKalmanFilter(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 pickupId, + 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 pickupType = '', + 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 = { + 'pickupid': pickupId, + 'orderheaderid': orderHeaderId, + 'pickuplocationid': pickupLocationId, + 'orderstatus': 'picked', + 'pickuptime': _pickedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'pickuplat': '', + 'pickuplong': '', + 'actualkms': calculatedKms.toStringAsFixed(2), + 'pickupamt': 0.0, + 'pickuptype': pickupType, + '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 resp = await _updateProvider.updatePickup(payload); + final ok = _isSuccess(resp); + + if (!ok) { + debugPrint('[UPDATE][PICKED][FAILED] resp=${jsonEncode(resp)}'); + } else { + // --- MQTT LOGIC --- + MilerMqttService().publishLog('pickup_picked', payload); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][PICKED][ERROR] $e'); + return false; + } + } + + // Picked up + Future updatePickedupStatus({ + required int pickupId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + int pickupLocationId = 0, + int smsPickup = 0, + String pickupLat = '0', + String pickupLng = '0', + String notes = '', + double pickupAmount = 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 { + pickedShimmer.value = true; + // ✅ PARALLEL OPTIMIZATION: Start fetching Prefs immediately + final prefsFuture = SharedPreferences.getInstance(); + + 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 dLat = double.tryParse(pickupLat) ?? 0.0; + final dLng = double.tryParse(pickupLng) ?? 0.0; + + // Geofence Check: Picked up -> Pickup Location + final inFence = await _checkGeofence(dLat, dLng, rLat, rLng, 'Picked up'); + if (!inFence) { + pickedShimmer.value = false; + return false; + } + + debugPrint( + '[Picked up] 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_$pickupId'; + 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('[Picked up] riderTimeMinutes=$riderTimeMinutes'); + } catch (e) { + debugPrint('[Picked up] 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 Picked up (most accurate) + // This is the actual distance the rider traveled, calculated from GPS points every 30 seconds + try { + final cumulativeKmStr = + prefs.getString('pickup_tracking_${pickupId}_cumulativeKm') ?? ''; + if (cumulativeKmStr.isNotEmpty) { + final cumulativeKm = double.tryParse(cumulativeKmStr) ?? 0.0; + if (cumulativeKm > 0) { + calculatedRiderKms = cumulativeKm; + debugPrint( + '[Picked up] ✅ Using CUMULATIVE tracked distance: ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + debugPrint( + '[Picked up] 📊 This is the actual distance traveled from active to Picked up', + ); + } + } + } catch (e) { + debugPrint('[Picked up] 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( + '[Picked up] 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 _getPickupStartLocation(pickupId); + 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( + '[Picked up] ✅ 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( + '[Picked up] ⚠️ 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( + '[Picked up] ✅ 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( + '[Picked up] ⚠️ 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( + '[Picked up] ✅ 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( + '[Picked up] ⚠️ Fallback straight-line (Saved Pickup): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + + // Method 4: Final fallback - calculate from pickup location to current location + if (calculatedRiderKms == 0.0 && + pickupLat != '0' && + pickupLng != '0') { + final dLat = double.tryParse(pickupLat) ?? 0.0; + final dLng = double.tryParse(pickupLng) ?? 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( + '[Picked up] ✅ Route distance (Pickup Location): ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line + final distanceMeters = Geolocator.distanceBetween( + dLat, + dLng, + riderLat, + riderLng, + ); + calculatedRiderKms = distanceMeters / 1000.0; + debugPrint( + '[Picked up] ⚠️ Fallback straight-line (Pickup Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + } catch (e) { + debugPrint('[Picked up] 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( + '[Picked up] ⚠️ WARNING: Could not calculate distance, using minimum 0.01 km (10 meters)', + ); + calculatedRiderKms = 0.01; // Minimum 10 meters + } + + debugPrint( + '[Picked up] Final riderkms: ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + + // ---- BONUS POINTS LOGIC ---- + int bonusPoints = 0; + // Reset per stop: these describe the stop being closed right now, and a + // stale value from the previous one would be stamped onto this record. + lastOnTime.value = null; + lastLateBy.value = null; + lastRiderKms.value = calculatedRiderKms; + if (orderId.isNotEmpty) { + try { + final endKey = 'eta_endtime_$orderId'; + final endSeconds = prefs.getInt(endKey); + + if (endSeconds != null) { + final currentSeconds = + DateTime.now().millisecondsSinceEpoch ~/ 1000; + lastOnTime.value = currentSeconds <= endSeconds; + lastLateBy.value = currentSeconds <= endSeconds + ? Duration.zero + : Duration(seconds: currentSeconds - endSeconds); + // If Picked up before or at the end time, give bonus points + if (currentSeconds <= endSeconds) { + // Bonus points = riderkms rounded + bonusPoints = calculatedRiderKms.round(); + debugPrint( + '[Picked up] 🏆 ON TIME! Bonus Points: $bonusPoints (RiderKms: $calculatedRiderKms)', + ); + } else { + debugPrint( + '[Picked up] ⏳ LATE! No Bonus Points. (Deadline: $endSeconds, Now: $currentSeconds)', + ); + } + } else { + debugPrint('[Picked up] No ETA timer found for bonus logic.'); + } + } catch (e) { + debugPrint('[Picked up] 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( + '[Picked up] ⚠️ 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 pickuptime to current time right before creating payload + // This ensures pickuptime is ALWAYS passed when confirming pickup, no matter what + // Setting it here (right before payload) ensures it's the exact time of confirmation + final currentTime = DateTime.now(); + final currentPickupTime = _formatDateTimeFull(currentTime); + _pickedTime = currentPickupTime; // Update the instance variable too + + debugPrint('[Picked up] Setting pickuptime to: $currentPickupTime'); + + // 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( + '[Picked up] ❌ ERROR: Invalid rider coordinates (lat=$ridersLatStr, lng=$ridersLngStr) - cannot proceed', + ); + pickedShimmer.value = false; + return false; // Don't send invalid coordinates + } + + // Validate pickup coordinates + final pickupLatDouble = double.tryParse(pickupLat) ?? 0.0; + final pickupLngDouble = double.tryParse(pickupLng) ?? 0.0; + if (pickupLatDouble == 0 || + pickupLngDouble == 0 || + pickupLatDouble.abs() > 90 || + pickupLngDouble.abs() > 180) { + debugPrint( + '[Picked up] ❌ ERROR: Invalid pickup coordinates (lat=$pickupLat, lng=$pickupLng) - cannot proceed', + ); + pickedShimmer.value = false; + return false; // Don't send invalid coordinates + } + + debugPrint( + '[Picked up] ✅ Validated coordinates - Rider: ($ridersLatStr, $ridersLngStr), Pickup: ($pickupLat, $pickupLng)', + ); + + final payload = { + 'pickupid': pickupId, + 'orderheaderid': orderHeaderId, + 'pickuplocationid': pickupLocationId, + 'orderstatus': 'Picked up', + 'pickupedtime': currentPickupTime, + 'pickuptime': + currentPickupTime, // ALWAYS pass current time - required field, set right before payload + 'smspickup': smsPickup, + '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, // Guaranteed non-zero and valid + 'pickuplong': pickupLng, // Guaranteed non-zero and valid + 'riderkms': + riderKmsString, // ALWAYS pass riderkms - never null, shows even 10 meters (0.0100 km) + 'ridercharges': riderChargesAmount, + 'pickupamt': pickupAmount, + '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('[Picked up] Payload includes pickuptime: $currentPickupTime'); + debugPrint('[Picked up] Payload includes riderkms: $riderKmsString km'); + debugPrint('[Picked up] Payload includes bonuspts: $bonusPoints'); + + final resp = await _updateProvider.updatePickup(payload); + final ok = _isSuccess(resp); + + // Save current RIDER GPS location as last pickup location for next pickup + if (ok) { + final actualLat = ll['lat'] ?? '0'; + final actualLng = ll['lng'] ?? '0'; + if (actualLat != '0' && actualLng != '0') { + await _saveLastPickupLocation(actualLat, actualLng); + } + + // CRITICAL: Clean up cumulative distance tracking after pickup is completed + try { + final cumulativeKm = + prefs.getString('pickup_tracking_${pickupId}_cumulativeKm') ?? ''; + if (cumulativeKm.isNotEmpty) { + debugPrint( + '[Picked up] 📊 Final cumulative distance used: $cumulativeKm km', + ); + } + // Clean up tracking data + await prefs.remove('pickup_tracking_${pickupId}_lastLat'); + await prefs.remove('pickup_tracking_${pickupId}_lastLng'); + await prefs.remove('pickup_tracking_${pickupId}_cumulativeKm'); + await prefs.remove('ridertime_start_$pickupId'); + + final activeTracking = + prefs.getStringList('active_tracking_pickup_ids') ?? []; + if (activeTracking.contains(pickupId.toString())) { + activeTracking.remove(pickupId.toString()); + await prefs.setStringList( + 'active_tracking_pickup_ids', + activeTracking, + ); + } + + // ✅ Clean up ETA timer + if (orderId.isNotEmpty) { + await prefs.remove('eta_endtime_$orderId'); + } + debugPrint( + '[Picked up] 🧹 Cleaned up distance tracking for pickupId: $pickupId', + ); + } catch (e) { + debugPrint('[Picked up] Error cleaning up tracking: $e'); + } + } + + // Reset foreground pickup notification flag + await setNotificationSent(false); + pickedShimmer.value = false; + + if (!ok) { + debugPrint('[UPDATE][Picked up][FAILED] resp=${jsonEncode(resp)}'); + } else { + // --- MQTT LOGIC --- + MilerMqttService().publishLog('pickup_completed', payload); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][Picked up][ERROR] $e'); + pickedShimmer.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.updatePickupLive + : ApiConstants.updatePickupDev; + } + + // ---------------- SharedPrefs helpers ---------------- + Future setNotificationSent(bool value) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('notificationSent', value); + } catch (_) {} + } + + // ---------------- Geofencing helpers ---------------- + Future _getPickupRadius() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt('pickupradius') ?? 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) { + debugPrint('[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; + + AppFeedback.errorGlobal('Something went wrong'); + } catch (e) { + debugPrint( + '[GEOFENCE] Error showing snackbar (attempt ${retryCount + 1}): $e', + ); + debugPrint( + '[GEOFENCE] GetX context available: ${Get.key.currentContext != null}', + ); + + Future.delayed(const Duration(milliseconds: 400), () { + try { + AppFeedback.errorGlobal('Something went wrong'); + } catch (e2) { + debugPrint('[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 { + // Testing/demo bypass: skip proximity enforcement so mock stops far from + // the rider can still be completed. Remove/flip for production. + if (kBypassGeofenceForTesting) { + if (kDebugMode) { + debugPrint('[GEOFENCE] Bypassed for $action (testing flag on)'); + } + return true; + } + + // 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: ColorConstants.warning, + seconds: 3, + ); + return true; // Allow update to proceed despite missing coords + } + + try { + final radius = await _getPickupRadius(); + 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: ColorConstants.warning, + seconds: 3, + ); + return true; // Allow on error (fail-safe) + } + } + + // Get last pickup location (for calculating riderkms between pickup) + Future> _getLastPickupLocation() async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = prefs.getString('lastPickupLat') ?? ''; + final lng = prefs.getString('lastPickupLng') ?? ''; + if (lat.isNotEmpty && lng.isNotEmpty) { + return {'lat': lat, 'lng': lng}; + } + } catch (_) {} + return {'lat': '', 'lng': ''}; + } + + // Save last pickup location (rider's actual GPS location, not destination) + Future _saveLastPickupLocation(String lat, String lng) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('lastPickupLat', lat); + await prefs.setString('lastPickupLng', lng); + debugPrint('[TRACKING] Saved last pickup location: lat=$lat, lng=$lng'); + } catch (e) { + debugPrint('[TRACKING] Error saving last pickup location: $e'); + } + } + + // Save start location for a specific pickup (when "Start Navigation" is slid) + Future _savePickupStartLocation( + int pickupId, + String lat, + String lng, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('pickup_start_${pickupId}_lat', lat); + await prefs.setString('pickup_start_${pickupId}_lng', lng); + debugPrint( + '[TRACKING] Saved start location for pickup $pickupId: lat=$lat, lng=$lng', + ); + } catch (e) { + debugPrint('[TRACKING] Error saving pickup start location: $e'); + } + } + + // Get start location for a specific pickup + Future> _getPickupStartLocation(int pickupId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = prefs.getString('pickup_start_${pickupId}_lat') ?? ''; + final lng = prefs.getString('pickup_start_${pickupId}_lng') ?? ''; + if (lat.isNotEmpty && lng.isNotEmpty) { + return {'lat': lat, 'lng': lng}; + } + } catch (_) {} + return {'lat': '', 'lng': ''}; + } + + // Get pickup location (for first pickup 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': ''}; + } + + /// Road distance in kilometres between two points, or null when the router + /// cannot answer — the caller then falls back to straight-line. + /// + /// ── Why this stopped being a Google call ── + /// + /// It asked the Directions API directly, with the same key that is revoked + /// everywhere else in this app. Every request came back `REQUEST_DENIED`, the + /// method logged it and returned null, and the caller quietly used the + /// straight-line distance instead. Nothing looked broken — but road distance + /// is what riders are *paid* on, and a crow-flies figure is short on every + /// trip that is not a straight road. + /// + /// OSRM answers the same question for free. See [MilerRouter]. + Future _getRouteDistanceKm( + double originLat, + double originLng, + double destLat, + double destLng, + ) async { + // Validate coordinates. + if (originLat == 0 || + originLng == 0 || + destLat == 0 || + destLng == 0 || + originLat.abs() > 90 || + originLng.abs() > 180 || + destLat.abs() > 90 || + destLng.abs() > 180) { + debugPrint('[ROUTE] Invalid coordinates for route calculation'); + return null; + } + + final metres = await MilerRouter.roadDistanceMetres( + LatLng(originLat, originLng), + LatLng(destLat, destLng), + ); + if (metres == null) { + debugPrint( + '[ROUTE] No road distance — caller falls back to a straight line.', + ); + return null; + } + + final km = metres / 1000.0; + debugPrint('[ROUTE] Road distance: ${km.toStringAsFixed(4)} km'); + return km; + } + + // 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; + } + + // ---------------- Status Updates ---------------- + + // Accepted + Future updateAcceptedStatus({ + required int pickupId, + 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 = { + 'pickupid': pickupId, + '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', + 'pickuplat': '', + 'pickuplong': '', + 'actualkms': '', + 'pickupamt': 0.0, + 'notes': notes, + }; + + final resp = await _updateProvider.updatePickup(payload); + 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 pickupId, + 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('pickup_starttime_$pickupId') ?? ''; + + 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 = { + 'pickupid': pickupId, + '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', + 'pickuplat': '', + 'pickuplong': '', + 'actualkms': '', + 'pickupamt': 0.0, + 'notes': notes, + }; + + final resp = await _updateProvider.updatePickup(payload); + final ok = _isSuccess(resp); + + if (ok) { + final lat = ll['lat'] ?? '0'; + final lng = ll['lng'] ?? '0'; + if (lat != '0' && lng != '0') { + await _savePickupStartLocation(pickupId, lat, lng); + } + + // Save starttime to SharedPreferences for pickup logs (using pickupId as key) + // This will be retrieved when creating pickup log payloads + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('pickup_starttime_$pickupId', startTime); + if (orderId.isNotEmpty) { + await prefs.setString('current_riding_order_id', orderId); + } + await prefs.setString( + 'current_riding_pickup_id', + pickupId.toString(), + ); + debugPrint( + '[UPDATE][ACTIVE] Saved starttime: $startTime for pickupId: $pickupId', + ); + + // CRITICAL: Initialize cumulative distance tracking when pickup becomes active + // Reset any previous tracking data and set initial location + await prefs.setString('pickup_tracking_${pickupId}_lastLat', lat); + await prefs.setString('pickup_tracking_${pickupId}_lastLng', lng); + await prefs.setString( + 'pickup_tracking_${pickupId}_cumulativeKm', + '0.0', + ); + + final activeTracking = + prefs.getStringList('active_tracking_pickup_ids') ?? []; + if (!activeTracking.contains(pickupId.toString())) { + activeTracking.add(pickupId.toString()); + await prefs.setStringList( + 'active_tracking_pickup_ids', + activeTracking, + ); + } + + debugPrint( + '[UPDATE][ACTIVE] 🚀 Initialized distance tracking for pickupId: $pickupId 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 pickupId, + 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 = { + 'pickupid': pickupId, + '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', + 'pickuplat': '', + 'pickuplong': '', + 'actualkms': '', + 'pickupamt': 0.0, + 'notes': notes, + }; + + final resp = await _updateProvider.updatePickup(payload); + 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 pickupId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String notes = '', + }) async { + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final payload = { + 'pickupid': pickupId, + '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', + 'pickuplat': '', + 'pickuplong': '', + 'actualkms': '', + 'pickupamt': 0.0, + 'notes': notes, + }; + + final resp = await _updateProvider.updatePickup(payload); + 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 (pickup cancelled by rider) + Future updateCancelledStatus({ + required int pickupId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String pickupLat = '0', + String pickupLng = '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(pickupLat) ?? 0.0; + final dLng = double.tryParse(pickupLng) ?? 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 -> Pickup 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('pickup_tracking_${pickupId}_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 lastPickup = await _getLastPickupLocation(); + final lastLat = double.tryParse(lastPickup['lat'] ?? '') ?? 0.0; + final lastLng = double.tryParse(lastPickup['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 pickup - 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 pickup + final startLoc = await _getPickupStartLocation(pickupId); + 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('pickup_tracking_${pickupId}_cumulativeKm') ?? ''; + if (cumulativeKm.isNotEmpty) { + debugPrint( + '[CANCELLED] 📊 Final cumulative distance used: $cumulativeKm km', + ); + } + // Clean up tracking data + await prefs.remove('pickup_tracking_${pickupId}_lastLat'); + await prefs.remove('pickup_tracking_${pickupId}_lastLng'); + await prefs.remove('pickup_tracking_${pickupId}_cumulativeKm'); + + final activeTracking = + prefs.getStringList('active_tracking_pickup_ids') ?? []; + if (activeTracking.contains(pickupId.toString())) { + activeTracking.remove(pickupId.toString()); + await prefs.setStringList( + 'active_tracking_pickup_ids', + activeTracking, + ); + } + + debugPrint( + '[CANCELLED] 🧹 Cleaned up distance tracking for pickupId: $pickupId', + ); + } 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 = { + 'pickupid': pickupId, + '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', + 'pickuplat': '', + 'pickuplong': '', + 'riderkms': calculatedKms.toStringAsFixed(2), + 'ridercharges': riderChargesAmount, + 'pickupamt': 0.0, + 'notes': notes, + }; + + final resp = await _updateProvider.updatePickup(payload); + final ok = _isSuccess(resp); + + if (ok) { + // Update last pickup location to current location so next Booking starts here + final actualLat = ll['lat'] ?? '0'; + final actualLng = ll['lng'] ?? '0'; + if (actualLat != '0' && actualLng != '0') { + await _saveLastPickupLocation(actualLat, actualLng); + } + } + + if (!ok) { + debugPrint('[UPDATE][CANCELLED][FAILED] resp=${jsonEncode(resp)}'); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][CANCELLED][ERROR] $e'); + return false; + } + } + + // Skipped (pickup skipped by rider) + Future updateSkippedStatus({ + required int pickupId, + 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 = { + 'pickupid': pickupId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'skipped', + 'skippedtime': skippedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'pickuplat': '', + 'pickuplong': '', + 'actualkms': '', + 'pickupamt': 0.0, + 'notes': notes, + }; + + // Calculate distance for skipped Booking (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 _getPickupStartLocation(pickupId); + 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 Pickup Location + final lastPickup = await _getLastPickupLocation(); + final lastLat = double.tryParse(lastPickup['lat'] ?? '') ?? 0.0; + final lastLng = double.tryParse(lastPickup['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 Pickup): ${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 Pickup): ${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 resp = await _updateProvider.updatePickup(payload); + final ok = _isSuccess(resp); + + if (ok) { + // Update last pickup location to current location so next Booking starts here + final actualLat = ll['lat'] ?? '0'; + final actualLng = ll['lng'] ?? '0'; + if (actualLat != '0' && actualLng != '0') { + await _saveLastPickupLocation(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 pickup + /// Returns true ONLY if has_live_pickup is true (verified by API) + /// This ensures PiP is only enabled when there are actually active pickup + Future hasActivePickups() async { + try { + final prefs = await SharedPreferences.getInstance(); + final hasLive = prefs.getBool('has_live_pickup') ?? false; + + // ✅ CRITICAL: Only return true if has_live_pickup is true + // Don't rely on active_pickup_order_id alone - it may be stale + // The API sets has_live_pickup based on actual Booking status = "active" + if (!hasLive) { + // Clear stale active_pickup_order_id if has_live_pickup is false + final activeOrderId = prefs.getString('active_pickup_order_id'); + if (activeOrderId != null && activeOrderId.isNotEmpty) { + debugPrint( + '[PICKUPS_CONTROLLER] Clearing stale active_pickup_order_id: $activeOrderId (no active pickup)', + ); + await prefs.remove('active_pickup_order_id'); + } + } + + debugPrint( + '[PICKUPS_CONTROLLER] hasActivePickups: $hasLive (has_live_pickup from API)', + ); + return hasLive; + } catch (e) { + debugPrint('[PICKUPS_CONTROLLER] Error checking active pickup: $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/controllers/profile_controller.dart b/lib/controllers/profile_controller.dart new file mode 100644 index 0000000..bad185d --- /dev/null +++ b/lib/controllers/profile_controller.dart @@ -0,0 +1,58 @@ +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(); + // The picked photo's path was being written on every change and never read + // back, so the avatar reverted to the initial on every cold start. It is + // restored here, and dropped if the file has since gone from disk — an + // image-picker path can point into a cache the OS is free to clear. + final String savedImage = (prefs.getString('profile_image_path') ?? '') + .trim(); + if (savedImage.isNotEmpty) { + bool stillThere = false; + try { + stillThere = File(savedImage).existsSync(); + } catch (_) {} + imagePath.value = stillThere ? savedImage : ''; + if (!stillThere) await prefs.remove('profile_image_path'); + } + 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/controllers/rewards_controller.dart b/lib/controllers/rewards_controller.dart new file mode 100644 index 0000000..73ca99d --- /dev/null +++ b/lib/controllers/rewards_controller.dart @@ -0,0 +1,45 @@ +import 'package:get/get.dart'; +import 'package:miler/data/miler_api.dart'; + +/// The rider's reward-points balance. +/// +/// ── Two things this used to do and no longer does ── +/// +/// It called a second, legacy backend (`jupiter.doormile.app/.../ +/// getuserbonussummary`) when the new-API flag was off, and it filled an empty +/// balance with seeded demo points in debug builds. Both are gone: there is one +/// backend now, and a points balance is a number about somebody's pay — the +/// only honest answer to "the server said zero" is zero. +/// +/// ── Why the number is expected to be zero ── +/// +/// `bonuspoints` is not written by anything server-side yet. `total_bonus` on +/// the earnings response is therefore always 0, and that is the backend's +/// answer rather than a bug here. See [MilerApi.missingFromBackend]. +class RewardsController extends GetxController { + final RxInt totalPoints = 0.obs; + final RxBool isLoading = true.obs; + final RxString error = ''.obs; + + Future fetchBonusSummary(int userId) async { + isLoading.value = true; + error.value = ''; + try { + // No dedicated bonus-summary endpoint exists; the figure rides on + // earnings. + final res = await MilerApi.earnings(period: 'monthly'); + if (res.ok) { + totalPoints.value = + int.tryParse('${res.map['total_bonus'] ?? 0}') ?? 0; + } else { + error.value = res.message.isEmpty + ? "Couldn't load your points" + : res.message; + } + } catch (e) { + error.value = "Couldn't load your points"; + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/controllers/riderkm.dart b/lib/controllers/riderkm.dart new file mode 100644 index 0000000..a133214 --- /dev/null +++ b/lib/controllers/riderkm.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:miler/Models/summary/riderweeklykms.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:miler/views/helpers/constants/apiconstants.dart'; + +class RiderWeeklyKmController { + final String baseUrl = ApiConstants.summaryriderkmLive; + + /// `GET /miler/earnings?period=weekly` → `data:{ total_kms, breakdown }`. + /// + /// [userId] is unused: the token identifies the rider, and asking for someone + /// else's kilometres is not a thing the endpoint offers. Kept on the + /// signature because the provider above passes it and the call sites read + /// better for naming whose distance they mean. + Future> getRiderWeeklyKms(int userId) async { + final res = await MilerApi.earnings(period: 'weekly'); + if (!res.ok) { + throw Exception('Failed to fetch (code: ${res.status})'); + } + final breakdown = res.map['breakdown']; + final details = (breakdown is List ? breakdown : const []) + .whereType() + .map((e) => RiderWeeklyKms.fromJson(Map.from(e))) + .toList(); + return { + 'details': details, + 'total_kms': (res.map['total_kms'] ?? 0).toDouble(), + }; + } +} diff --git a/lib/controllers/riderlog.dart b/lib/controllers/riderlog.dart new file mode 100644 index 0000000..1fff621 --- /dev/null +++ b/lib/controllers/riderlog.dart @@ -0,0 +1,1255 @@ +import 'package:get/get.dart'; +import 'package:flutter/foundation.dart'; +import 'dart:convert'; +import 'package:miler/views/helpers/constants/apiconstants.dart'; +import 'package:miler/Models/riders/riders_models.dart'; +import 'package:miler/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/helpers/shift_end_alarm.dart'; +import 'package:miler/controllers/logcontroller.dart'; +import 'package:miler/background/foreground_service.dart' as fg; +import 'package:miler/utils/kalman_filter.dart'; +import 'package:miler/utils/mqtt_service.dart'; +import 'package:miler/views/helpers/constants/mqtt_constants.dart'; +import 'package:battery_plus/battery_plus.dart'; +import 'package:miler/controllers/connectivity_mixin.dart'; + +class RiderLogController extends GetxController + with ConnectivityControllerMixin { + 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; + + MilerKalmanFilter? _kf; + DateTime? _lastUpdateTime; + + Future fetchRiderLogs({required String userId}) async { + isLoading.value = true; + try { + final response = await _getProvider.getRiderLog(); + + // 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 response = await _getProvider.getRiderCount(); + 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 ok = await _createProvider.createRiderLog( + _CreateRiderLogRequestCompat.fromRiderLogin(login), + ); + return ok != null && ok.isNotEmpty; + } + + Future updateLog(RiderUpdate update) async { + final ok = await _updateProvider.updateRiderLog( + _UpdateRiderLogRequestCompat.fromRiderUpdate(update), + ); + return ok != null && ok.isNotEmpty; + } + + Future createBreak(RiderBreak brk) async { + final ok = await _breakProvider.createBreakRiderLog( + _BreakLogRequestCompat.fromRiderBreak(brk), + ); + return ok != null && ok.isNotEmpty; + } + + /// The break's own payload is no longer sent: `PUT /miler/breaks/end` takes + /// no body and closes whichever break is open, which the server knows and the + /// app was only guessing at. + Future updateBreak(RiderBreak brk) async { + final ok = await _breakProvider.updateBreakRiderLog(); + 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 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] Body: $payload'); + + final response = await _breakProvider.updateBreakRiderLog(); + + // 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 pickup to set status + final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; + final String riderStatus = hasActivePickups ? 'active' : 'idle'; + final String orderId = prefs.getString('current_riding_order_id') ?? ''; + debugPrint( + '[RIDERLOG][CREATE LOGIN NOW] Active pickup: $hasActivePickups -> 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 = MilerMqttService(); + 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'); + } + + debugPrint('[RIDERLOG][CREATE LOGIN NOW] duty/location'); + debugPrint('[RIDERLOG][CREATE LOGIN NOW] Payload: $payload'); + + final resp = await _createProvider.createRiderLog(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('/miler/logs', 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 hasActivePickups = + prefs.getBool('has_live_pickup') ?? false; + final String riderStatus = hasActivePickups ? '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'); + } + + + await _saveToOfflineQueue('/miler/logs', 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; + + // 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, + 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 + MilerMqttService().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 --- + MilerMqttService().updateStatus(MqttConstants.statusOffline); + } + } + return ok; + } catch (_) { + return false; + } + } + + // Start periodic createRiderLog calls based on seconds (or prefs 'logseconds') + // ✅ CRITICAL: When there are active pickup, use 30 seconds (same as pickup logs) + // Otherwise, use the configured logseconds interval + void startAutoCreateLoginLoop({int? seconds}) async { + final prefs = await SharedPreferences.getInstance(); + + // ✅ Check if there are active pickup - if yes, use 30 seconds (same as pickup logs) + final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; + final int baseInterval = seconds ?? (prefs.getInt('logseconds') ?? 0); + + // When there are active pickup, post rider logs every 30 seconds (matching pickup logs) + // Otherwise, use the configured interval + final int interval = hasActivePickups ? 30 : baseInterval; + + if (hasActivePickups && interval != 30) { + debugPrint( + '[RIDERLOG][AUTO LOOP] Active pickup detected - using 30 second interval (matching pickup 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 Miler 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: 'Miler 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. + // ServiceRequestResult is a class with a `success` bool — compare that, + // not the value against the `success()` factory (which is always true). + if (started?.success != true) { + 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) { + MilerMqttService().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 = MilerKalmanFilter(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 + + + // 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] Payload: $payload'); + + // Persist local break id immediately to ensure update can reference it + await prefs.setInt('breakId', localBreakId); + + final resp = await _breakProvider.createBreakRiderLog(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); + + + // ✅ Check if there are active pickup to set status + final bool hasActivePickups = prefs.getBool('has_live_pickup') ?? false; + final String riderStatus = hasActivePickups ? '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] Payload: $payload'); + + final resp = await _createProvider.createRiderLog(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(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/controllers/summary_controller.dart b/lib/controllers/summary_controller.dart new file mode 100644 index 0000000..35161fc --- /dev/null +++ b/lib/controllers/summary_controller.dart @@ -0,0 +1,58 @@ +import 'package:get/get.dart'; +import 'package:miler/Models/summary/pickupstats.dart'; +import 'package:miler/providers/summary/summary.dart'; +import 'package:flutter/foundation.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 PickupStats? stats = await _provider.fetchSummaryStats(userId); + // A record of all zeros is what a freshly-seeded account returns, and it + // is indistinguishable from no record at all — treat both as empty so + // the demo figures can fill in. + if (stats != null && (stats.total > 0 || stats.month > 0)) { + today.value = stats.today; + week.value = stats.week; + month.value = stats.month; + total.value = stats.total; + cancelled.value = stats.cancelled; + } else { + _applyZero(); + } + } catch (e) { + debugPrint('Controller error: $e'); + _applyZero(); + } finally { + isLoading.value = false; + } + } + + /// Nothing came back — show zeros. + /// + /// This used to fill the tab with seeded demo figures in debug builds. That + /// was defensible while the endpoint was unfinished and indefensible now that + /// it is live: the one thing a pay screen must never do is invent a number, + /// and a debug-only invention is still the number the person testing the app + /// reports back as real. An empty Earnings tab is information — it says the + /// fetch returned nothing — and the honest zero is what makes a broken + /// endpoint visible instead of plausible. + void _applyZero() { + today.value = 0; + week.value = 0; + month.value = 0; + total.value = 0; + cancelled.value = 0; + } +} diff --git a/lib/data/accepted_store.dart b/lib/data/accepted_store.dart new file mode 100644 index 0000000..704702d --- /dev/null +++ b/lib/data/accepted_store.dart @@ -0,0 +1,311 @@ +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 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'; + +/// Stops finished today, for the Activity tab. +/// +/// ── Why a local store and not just the API ── +/// +/// Finishing a stop is optimistic everywhere in this app: the confirm sheet +/// records the completion and moves the rider on whether or not the status call +/// succeeded, because a rider who watches a completed stop bounce back stops +/// trusting the button. That leaves the completion existing *only* in the +/// Bookings tab's in-memory state — it is dropped from the working list and +/// removed from the accepted store, and nothing else remembers it. +/// +/// So Activity, which reads the queue endpoints, showed nothing at all: on demo +/// data the backend never returns the finished stop, and on live data it does +/// not return it until the write lands and the next poll comes round. The +/// rider finished a booking and the tab meant to prove it stayed empty. +/// +/// 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'; + +/// Stops the rider parked mid-shift for a return visit. +/// +/// ── Why these are stored at all ── +/// +/// A skip used to live in one place only: `_MyPickupsState._skippedOrdersCache`, +/// a static map in the Bookings screen's State. That was enough while the skip +/// was displayed on Home, because Home is rebuilt from the same queue the cache +/// re-stamps. It is not enough now that a skipped stop is shown on **Activity**, +/// which reads its own endpoints: on demo data the backend never reports the +/// skip at all, and on live data it does not report it until the write lands. +/// The rider would skip a stop and find the tab that is meant to account for it +/// empty — the same failure the completed store was written to fix. +/// +/// 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'; + +List> _decode(String? raw) { + if (raw == null || raw.isEmpty) return []; + try { + final decoded = jsonDecode(raw); + if (decoded is List) { + return decoded + .whereType() + .map((e) => Map.from(e)) + .toList(); + } + } catch (_) {} + return []; +} + +/// All bookings that have been locally accepted (each carries +/// `orderstatus: 'accepted'`). +Future>> getAcceptedBookings() async { + final prefs = await SharedPreferences.getInstance(); + return _decode(prefs.getString(_kAcceptedBookingsKey)); +} + +/// The set of order ids that have been locally accepted. +Future> getAcceptedOrderIds() async { + final list = await getAcceptedBookings(); + return list + .map((b) => (b['orderid'] ?? '').toString()) + .where((s) => s.isNotEmpty) + .toSet(); +} + +/// Order ids the rider has locally rejected. Home filters these out so a +/// rejected booking leaves the pending list. +Future> getRejectedOrderIds() async { + final prefs = await SharedPreferences.getInstance(); + return (prefs.getStringList(_kRejectedOrderIdsKey) ?? []).toSet(); +} + +/// Remember the given order ids as rejected. +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(); + existing.addAll(clean); + await prefs.setStringList(_kRejectedOrderIdsKey, existing.toList()); +} + +/// Undo a rejection. +/// +/// Rejecting is one tap on a moving bike, so it is genuinely easy to hit the +/// wrong stop. Without this the mistake is permanent for the rest of the shift +/// and the rider has to call the hub to fix it. +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); +} + +/// Remove the given order ids from the accepted store. Called when a pickup is +/// completed or cancelled so it stops counting as an open booking (and no longer +/// reappears on the Bookings tab or as a "next stop" after a rebuild). +Future removeAcceptedBookings(List ids) async { + final drop = ids.where((s) => s.isNotEmpty).toSet(); + if (drop.isEmpty) return; + final prefs = await SharedPreferences.getInstance(); + final remaining = _decode( + prefs.getString(_kAcceptedBookingsKey), + ).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList(); + await prefs.setString(_kAcceptedBookingsKey, jsonEncode(remaining)); +} + +/// Stops the rider finished **today**, newest first. +/// +/// Entries from earlier days are dropped on read rather than on a timer, so a +/// shift that runs past midnight keeps its stops until the rider next opens the +/// tab — and the store cannot grow without bound. +Future>> getCompletedBookings() async { + final prefs = await SharedPreferences.getInstance(); + final all = _decode(prefs.getString(_kCompletedBookingsKey)); + final today = _dayStamp(DateTime.now()); + + final mine = all.where((b) => (b['completedday'] ?? '') == today).toList() + ..sort( + (a, b) => (b['completedat'] ?? '').toString().compareTo( + (a['completedat'] ?? '').toString(), + ), + ); + + // Prune in the background if yesterday's rows are still in there. + if (mine.length != all.length) { + await prefs.setString(_kCompletedBookingsKey, jsonEncode(mine)); + } + return mine; +} + +/// Prefs key holding the moment the rider opened a stop and set off for it. +/// Written by the stop map screen, read and cleared here. See [kStopStartedKey]. +String kStopStartedKey(Object pickupId) => 'stop_started_$pickupId'; + +/// Prefs key holding the moment the rider confirmed he was at the door. +/// Written by the stop map screen when it starts the pickup. +String kStopArrivedKey(Object pickupId) => 'pickup_start_$pickupId'; + +/// Record a finished stop. [cancelled] separates "could not complete" from a +/// clean pickup/delivery — Activity shows both, worded differently. +/// +/// ── Why the timings are collected here ── +/// +/// A finished stop is a record, and the questions asked of it afterwards are +/// all about *time*: when did he set off, when did he get there, how long did +/// the door take, did he beat the ETA. Every one of those was being measured +/// somewhere in the app and then dropped on the floor — the two timestamps live +/// in SharedPreferences keys that the next stop overwrites, so by the time +/// anyone opened Activity they were gone or, worse, belonged to a different +/// stop. +/// +/// 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. +Future addCompletedBookings( + List> bookings, { + bool cancelled = false, +}) async { + if (bookings.isEmpty) return; + final prefs = await SharedPreferences.getInstance(); + final now = DateTime.now(); + + final Map> byId = { + for (final b in _decode(prefs.getString(_kCompletedBookingsKey))) + (b['orderid'] ?? '').toString(): b, + }; + + for (final b in bookings) { + final id = (b['orderid'] ?? '').toString(); + if (id.isEmpty) continue; + final copy = Map.from(b); + // 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'; + copy['completedat'] = now.toIso8601String(); + copy['completedday'] = _dayStamp(now); + + final pickupId = (b['pickupid'] ?? '').toString(); + if (pickupId.isNotEmpty) { + final started = prefs.getString(kStopStartedKey(pickupId)); + final arrived = prefs.getString(kStopArrivedKey(pickupId)); + if (started != null && started.isNotEmpty) copy['startedat'] = started; + if (arrived != null && arrived.isNotEmpty) copy['arrivedat'] = arrived; + // Cleared, or a stop worked twice in a day (a resumed skip) reports the + // first attempt's clock against the second attempt's completion. + await prefs.remove(kStopStartedKey(pickupId)); + await prefs.remove(kStopArrivedKey(pickupId)); + } + + byId[id] = copy; + } + + await prefs.setString( + _kCompletedBookingsKey, + jsonEncode(byId.values.toList()), + ); +} + +/// Stops skipped **today**, newest first. +/// +/// Pruned on read like [getCompletedBookings]: a skip is a "come back to it +/// this shift" marker, and one left over from yesterday is noise the rider can +/// no longer act on. +Future>> getSkippedBookings() async { + final prefs = await SharedPreferences.getInstance(); + final all = _decode(prefs.getString(_kSkippedBookingsKey)); + final today = _dayStamp(DateTime.now()); + + final mine = all.where((b) => (b['skippedday'] ?? '') == today).toList() + ..sort( + (a, b) => (b['skippedat'] ?? '').toString().compareTo( + (a['skippedat'] ?? '').toString(), + ), + ); + + if (mine.length != all.length) { + await prefs.setString(_kSkippedBookingsKey, jsonEncode(mine)); + } + return mine; +} + +/// The set of order ids currently parked as skipped. +Future> getSkippedOrderIds() async { + final list = await getSkippedBookings(); + return list + .map((b) => (b['orderid'] ?? '').toString()) + .where((s) => s.isNotEmpty) + .toSet(); +} + +/// Record a skip, with the reason the rider picked in the skip sheet. +Future addSkippedBooking( + Map booking, { + String reason = '', +}) async { + final id = (booking['orderid'] ?? '').toString(); + if (id.isEmpty) return; + + final prefs = await SharedPreferences.getInstance(); + final now = DateTime.now(); + + final Map> byId = { + for (final b in _decode(prefs.getString(_kSkippedBookingsKey))) + (b['orderid'] ?? '').toString(): b, + }; + + final copy = Map.from(booking); + // Stamped so `stopStatusOf` reads it as skipped wherever it surfaces — the + // same trick the completed store uses, so one status test serves both. + copy['orderstatus'] = 'skipped'; + copy['skipreason'] = reason; + copy['skippedat'] = now.toIso8601String(); + copy['skippedday'] = _dayStamp(now); + byId[id] = copy; + + await prefs.setString(_kSkippedBookingsKey, jsonEncode(byId.values.toList())); +} + +/// Forget a skip — the rider resumed the stop, so it is live work again. +Future removeSkippedBookings(List ids) async { + final drop = ids.where((s) => s.isNotEmpty).toSet(); + if (drop.isEmpty) return; + final prefs = await SharedPreferences.getInstance(); + final remaining = _decode( + prefs.getString(_kSkippedBookingsKey), + ).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList(); + await prefs.setString(_kSkippedBookingsKey, jsonEncode(remaining)); +} + +String _dayStamp(DateTime t) => + '${t.year}-${t.month.toString().padLeft(2, '0')}-' + '${t.day.toString().padLeft(2, '0')}'; + +/// 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))) + (b['orderid'] ?? '').toString(): b, + }; + for (final b in bookings) { + final id = (b['orderid'] ?? '').toString(); + if (id.isEmpty) continue; + final copy = Map.from(b); + copy['orderstatus'] = 'accepted'; + byId[id] = copy; + } + await prefs.setString( + _kAcceptedBookingsKey, + jsonEncode(byId.values.toList()), + ); +} diff --git a/lib/data/api_config.dart b/lib/data/api_config.dart new file mode 100644 index 0000000..9e3c987 --- /dev/null +++ b/lib/data/api_config.dart @@ -0,0 +1,272 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Base URL, bearer token, and the adapter that turns a v1 booking into the +/// legacy stop shape the UI reads. +/// +/// ── The flag is gone ── +/// +/// This class used to carry `useNewApi`, a `--dart-define` switch between the +/// v1 backend and a legacy one (`jupiter.doormile.app` / `queue.workolik.com`). +/// Every provider branched on it, so the app shipped two implementations of +/// every call and only one of them was ever exercised. +/// +/// It was also the switch that turned the mock layer on: `USE_NEW_API=false` +/// gave a fake "Demo Rider" login that accepted any four digits, seeded demo +/// routes, and invented earnings figures. A build flag that silently swaps +/// authentication for a bypass is not a development convenience. +/// +/// One backend, one path, no flag. `MilerApi` is where the endpoints live; what +/// remains here is the base URL, the token, and the booking→stop mapping. +class ApiConfig { + ApiConfig._(); + + /// API base URL (no trailing slash). + static const String newBase = 'https://api.doormile.com/api/v1'; + + static String url(String path) => + '$newBase${path.startsWith('/') ? path : '/$path'}'; + + // --------------------------------------------------------------------------- + // Bearer token + // --------------------------------------------------------------------------- + static const String _kTokenKey = 'authtoken'; + + static Future setToken(String token) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kTokenKey, token); + } + + static Future getToken() async { + final prefs = await SharedPreferences.getInstance(); + final t = prefs.getString(_kTokenKey); + return (t != null && t.isNotEmpty) ? t : null; + } + + static Future clearToken() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kTokenKey); + } + + /// Headers for every authenticated request (Content-Type + Bearer token). + static Future> authHeaders() async { + final headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + final token = await getToken(); + if (token != null) headers['Authorization'] = 'Bearer $token'; + return headers; + } + + // --------------------------------------------------------------------------- + // Response envelope adapter: {success,data,message} -> {status,details} + // --------------------------------------------------------------------------- + + /// Wrap a decoded new-API response into the legacy envelope the controllers + /// expect. `_isSuccess()` reads `status` (bool); readers read `details`. + static Map toLegacyEnvelope(dynamic decoded) { + if (decoded is Map) { + final success = decoded['success']; + final bool ok = success is bool ? success : success == true; + return { + 'status': ok, + 'code': ok ? 200 : 400, + 'message': decoded['message']?.toString() ?? '', + 'details': decoded['data'] ?? decoded['details'] ?? decoded, + }; + } + // Bare list/value response. + return {'status': true, 'code': 200, 'details': decoded}; + } + + /// A generic "it worked" legacy envelope (for no-op / not-yet-supported flows). + static Map okEnvelope([String message = '']) => + {'status': true, 'code': 200, 'message': message}; + + // --------------------------------------------------------------------------- + // STATUS mapping (new booking status <-> legacy orderstatus) + // --------------------------------------------------------------------------- + // + // New (in order): Miler_Assigned -> Pickup_Scheduled -> At_Customer + // -> Picked_Up -> Converted_To_Consignment -> Cancelled + // Legacy the UI branches on: accepted / active / arrived / "Picked up" + // / 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 ?? ''; + } + } + + // --------------------------------------------------------------------------- + // BOOKING -> legacy pickup object mapping + // --------------------------------------------------------------------------- + // + // The UI reads legacy lowercase keys (pickupid, orderid, orderstatus, + // 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. + static Map pickupFromBooking(Map booking) { + String s(dynamic v) => v == null ? '' : v.toString(); + // First non-null value among several candidate keys — makes the adapter + // tolerant of field-name variants (camelCase / snake_case / short forms) + // so a naming mismatch can't map bookings to empty ids and drop them. + dynamic pick(List keys) { + for (final k in keys) { + final v = booking[k]; + if (v != null && v.toString().trim().isNotEmpty) return v; + } + return null; + } + + // Some endpoints already return the legacy STOP shape the UI reads + // (orderid/pickupid/orderstatus/pickupcustomer). Translating that as if it + // were a new-booking object would map everything to empty ids (→ dropped) + // and lose fields the translation doesn't cover (step, tenant, amounts). + // Detect it and pass it through untouched. + final looksNew = + booking['bookingid'] != null || booking['bookingreference'] != null; + final looksLegacy = + booking['orderid'] != null || + booking['pickupid'] != null || + booking['orderstatus'] != null || + booking['pickupcustomer'] != null; + if (!looksNew && looksLegacy) { + return Map.from(booking); + } + + final id = pick(['bookingid', 'bookingId', 'id', 'booking_id', 'pickupid']); + final ref = pick([ + 'bookingreference', + 'bookingRef', + 'reference', + 'referenceno', + 'orderid', + ]); + final status = pick([ + 'status', + 'bookingstatus', + 'booking_status', + 'orderstatus', + 'state', + ]); + return { + // identity + 'pickupid': id, + 'orderid': ref ?? id, + 'orderheaderid': id, + 'bookingid': id, // keep original too + 'bookingreference': s(ref), + + // status + 'orderstatus': legacyStatusFromNew(s(status)), + + // pickup side + 'pickupcustomer': s( + pick([ + 'customername', + 'customerName', + 'customer_name', + 'name', + 'pickupcustomer', + ]), + ), + 'pickupcontactno': s( + pick([ + 'customerphone', + 'customerPhone', + 'customer_phone', + 'phone', + 'pickupcontactno', + ]), + ), + 'pickupaddress': s( + pick(['pickupaddress', 'pickupAddress', 'pickup_address']), + ), + 'pickuplat': s( + pick([ + 'pickuplatitude', + 'pickupLatitude', + 'pickup_latitude', + 'pickuplat', + ]), + ), + 'pickuplong': s( + pick([ + 'pickuplongitude', + 'pickupLongitude', + 'pickup_longitude', + 'pickuplong', + 'pickuplon', + ]), + ), + 'pickuplon': s( + pick([ + 'pickuplongitude', + 'pickupLongitude', + 'pickup_longitude', + 'pickuplong', + 'pickuplon', + ]), + ), + + // delivery / drop side + 'dropaddress': s( + pick(['deliveryaddress', 'deliveryAddress', 'delivery_address']), + ), + 'droplat': s( + pick(['deliverylatitude', 'deliveryLatitude', 'delivery_latitude']), + ), + 'droplon': s( + 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', + + // money — NOT provided by the new booking object yet (see gaps doc) + 'collectionamt': booking['collectionamt'] ?? 0, + 'pickupamt': booking['pickupamt'] ?? 0, + + // misc passthrough + 'parcels': booking['parcels'] ?? const [], + 'starttime': s(booking['createdat']), + 'eta': s(booking['eta']), + }; + } + + static List> pickupsFromBookings(dynamic data) { + if (data is List) { + return data.whereType().map((b) => pickupFromBooking(b)).toList(); + } + // A single booking object (some endpoints return one, not a list). + if (data is Map && + (data['bookingid'] != null || data['bookingreference'] != null)) { + return >[pickupFromBooking(data)]; + } + return >[]; + } + + static void logGap(String where, String detail) { + debugPrint('[API_GAP][$where] $detail'); + } +} diff --git a/lib/data/assignment_lookup.dart b/lib/data/assignment_lookup.dart new file mode 100644 index 0000000..9190351 --- /dev/null +++ b/lib/data/assignment_lookup.dart @@ -0,0 +1,139 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:miler/data/api_config.dart'; + +/// Resolves a BOOKING id into the BOOKING ASSIGNMENT id that the accept/reject +/// endpoints key on. +/// +/// Why this exists: `POST /miler/assignments/{id}/accept` looks the row up by +/// `bookingassignmentid`, but everything the app renders comes from +/// `GET /miler/bookings`, whose rows only carry `bookingid`. Those two ids come +/// from different sequences and do not match, so sending a booking id makes the +/// backend answer 404 "assignment not found" — the rider taps Accept, sees +/// success, and the booking is never actually accepted server-side. +/// +/// `GET /miler/assignments` is the only place the pairing is exposed, so we +/// fetch it and keep a short-lived bookingid -> bookingassignmentid map. +class AssignmentLookup { + AssignmentLookup._(); + + /// bookingid (as string) -> bookingassignmentid + static final Map _cache = {}; + static DateTime? _fetchedAt; + + /// Assignments change whenever the hub assigns work, so the map goes stale + /// quickly. Short enough to pick up new work, long enough that a burst of + /// accepts doesn't refetch per tap. + static const Duration _ttl = Duration(seconds: 30); + + /// Assignment states that are still actionable by the rider. A booking can + /// legitimately have several assignment rows over its life (assign → reject → + /// reassign), so when more than one exists we want the live one, not the + /// rejected corpse. + static const Set _actionable = {'Assigned', 'Accepted'}; + + static bool get _isStale { + final at = _fetchedAt; + return at == null || DateTime.now().difference(at) > _ttl; + } + + static void invalidate() { + _cache.clear(); + _fetchedAt = null; + } + + /// The assignment id for [bookingId], or null when the backend has no + /// assignment row for it (or the call fails). + /// + /// Refetches once on a cache miss before giving up: a booking the hub just + /// assigned will not be in a cache populated moments earlier, and that is + /// exactly the case the rider hits when accepting fresh work. + static Future idForBooking(dynamic bookingId) async { + final String key = bookingId?.toString().trim() ?? ''; + if (key.isEmpty) return null; + + if (_isStale) { + await _refresh(); + } + final cached = _cache[key]; + if (cached != null) return cached; + + // Miss against a warm cache — the assignment may have been created since. + await _refresh(); + return _cache[key]; + } + + 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}'); + 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}'); + return; + } + + final next = buildIndex(data); + _cache + ..clear() + ..addAll(next); + _fetchedAt = DateTime.now(); + debugPrint( + '[ASSIGNMENTS] cached ${_cache.length} booking->assignment ids', + ); + } catch (e) { + debugPrint('[ASSIGNMENTS] fetch error: $e'); + } + } + + /// Collapses the assignment rows into one bookingid -> bookingassignmentid + /// entry per booking. + /// + /// The backend orders by `assignedat DESC`, so the first row seen for a + /// booking is its newest. Keep that one, but let an actionable row override a + /// newer terminal one — a booking that was assigned, rejected, then + /// reassigned must resolve to the live assignment, not the rejected corpse. + @visibleForTesting + static Map buildIndex(List rows) { + final index = {}; + final tookActionable = {}; + + for (final row in rows.whereType()) { + final bookingKey = row['bookingid']?.toString().trim() ?? ''; + final assignmentId = _asInt(row['bookingassignmentid']); + if (bookingKey.isEmpty || assignmentId == null) continue; + + final status = (row['assignmentstatus'] ?? '').toString().trim(); + final isActionable = _actionable.contains(status); + + if (!index.containsKey(bookingKey)) { + index[bookingKey] = assignmentId; + if (isActionable) tookActionable.add(bookingKey); + } else if (isActionable && !tookActionable.contains(bookingKey)) { + index[bookingKey] = assignmentId; + tookActionable.add(bookingKey); + } + } + + return index; + } + + static int? _asInt(dynamic v) { + if (v is int) return v; + if (v is num) return v.toInt(); + return int.tryParse(v?.toString() ?? ''); + } +} diff --git a/lib/data/miler_api.dart b/lib/data/miler_api.dart new file mode 100644 index 0000000..f37a2c5 --- /dev/null +++ b/lib/data/miler_api.dart @@ -0,0 +1,662 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import 'package:miler/data/api_config.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE MILER API — every `/miler/*` route, in one place. +/// +/// Before this existed, endpoints were spelled out inline across five provider +/// files, a controller and a widget, each with its own idea of how to decode an +/// envelope and what counts as success. Half the contract was implemented and +/// the other half was a `logGap` call — and there was nowhere to look to find +/// out which half a given route was in. +/// +/// This is the whole surface: 3 auth routes and 35 authenticated ones. If a +/// route is not here, the backend does not have it (see [missingFromBackend]). +/// +/// ── Things the contract gets wrong if you guess ── +/// +/// Each of these cost a real debugging session, so they are encoded here rather +/// than left to the call site: +/// +/// • **`configid` is 1001.** It defaults server-side, but a miler row created +/// without it can never log in. Always sent. +/// • **`verify-pin` has no `data` key.** The login lands under `user` / +/// `user.profile`. An earlier contract doc said otherwise and was wrong. +/// • **Telemetry numbers are strings.** `latitude`, `longitude`, `speed`, +/// `heading`, `battery` on `/logs` and `/consignments/logs` fail to parse if +/// sent as JSON numbers. Everywhere else they are numbers. See [_str]. +/// • **Never send `userid` in a telemetry body.** Identity comes from the +/// token; a body `userid` used to let one rider write another's GPS into the +/// dispatch index. It is ignored now, but sending it is still wrong. +/// • **The break status is `Break`,** not `On_Break`. +/// • **`vehicle-required` reads query strings,** not a body. +/// • **`reject` reads its reason from the query string** in the deployed +/// handler, while the contract doc says body. Both are sent — see +/// [rejectAssignment]. +/// • **`/miler/reset-pin` is deliberately absent from this class.** It needs an +/// admin token, and it was once open: reset-pin followed by verify-pin took +/// over any rider account given only a phone number. Rider PIN resets go +/// through ops. Do not add it here. +/// ───────────────────────────────────────────────────────────────────────── +class MilerApi { + MilerApi._(); + + /// The partition riders live in. + static const int configId = 1001; + + static const Duration _timeout = Duration(seconds: 20); + + /// Telemetry wants strings for values that are numbers everywhere else. + static String _str(dynamic v) => v == null ? '' : v.toString(); + + /// `YYYY-MM-DD HH:MM:SS`, IST wall-clock, for telemetry `logdate`. + static String logStamp([DateTime? at]) { + final t = at ?? DateTime.now(); + String p(int n) => n.toString().padLeft(2, '0'); + return '${t.year}-${p(t.month)}-${p(t.day)} ' + '${p(t.hour)}:${p(t.minute)}:${p(t.second)}'; + } + + /// `YYYY-MM-DD`, for date filters. + static String dateStamp([DateTime? at]) { + final t = at ?? DateTime.now(); + String p(int n) => n.toString().padLeft(2, '0'); + return '${t.year}-${p(t.month)}-${p(t.day)}'; + } + + // ── Transport ────────────────────────────────────────────────────────── + + static Future _send( + String method, + String path, { + Object? body, + Map? query, + bool auth = true, + }) async { + 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 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); + case 'POST': + res = await http + .post(uri, headers: headers, body: encoded) + .timeout(_timeout); + case 'PUT': + res = await http + .put(uri, headers: headers, body: encoded) + .timeout(_timeout); + case 'PATCH': + res = await http + .patch(uri, headers: headers, body: encoded) + .timeout(_timeout); + default: + throw ArgumentError('Unsupported method $method'); + } + + dynamic decoded; + if (res.body.isNotEmpty) { + try { + decoded = json.decode(res.body); + } catch (_) { + decoded = res.body; + } + } + + final ok = res.statusCode >= 200 && res.statusCode < 300; + // The envelope is `{success, data}` / `{success, message}`. A 2xx with + // `success: false` is still a failure — the status line alone is not the + // answer. + final envelopeOk = decoded is Map ? decoded['success'] != false : true; + + final result = ApiResult( + ok: ok && envelopeOk, + status: res.statusCode, + data: decoded is Map ? (decoded['data'] ?? decoded) : decoded, + raw: decoded, + message: decoded is Map ? _str(decoded['message']) : '', + ); + + if (!result.ok) { + debugPrint( + '[API] $method $path -> ${res.statusCode} ${result.message} ' + '${res.body.length > 300 ? '${res.body.substring(0, 300)}…' : res.body}', + ); + } + return result; + } catch (e) { + debugPrint('[API] $method $path -> transport error: $e'); + return ApiResult(ok: false, status: 0, message: '$e'); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // AUTH (3 routes — reset-pin is intentionally not one of them) + // ═══════════════════════════════════════════════════════════════════════ + + /// Step 1. 404 when there is no account, 403 when the row is not role 5 or + /// not Active — both surface as `ok: false` with the server's message. + static Future login(String phone) => _send( + 'POST', + '/miler/login', + auth: false, + body: {'phone': phone, 'configid': configId}, + ); + + /// Step 2. On success the token is stored and the caller gets `user`. + /// + /// The login is under `user` / `user.profile` — there is no `data` key on + /// this response, whatever the older contract doc claimed. + static Future verifyPin({ + required String phone, + required String pin, + String? deviceToken, + }) async { + final res = await _send( + 'POST', + '/miler/verify-pin', + auth: false, + body: { + 'phone': phone, + 'pin': pin, + 'configid': configId, + if (deviceToken != null && deviceToken.isNotEmpty) + 'device_token': deviceToken, + }, + ); + if (res.ok && res.raw is Map) { + final token = _str((res.raw as Map)['token']); + if (token.isNotEmpty) await ApiConfig.setToken(token); + } + return res; + } + + // ═══════════════════════════════════════════════════════════════════════ + // PROFILE & DEVICE + // ═══════════════════════════════════════════════════════════════════════ + + static Future getProfile() => _send('GET', '/miler/profile'); + + static Future updateProfile({ + String? displayName, + 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, + }); + + /// snake_case, unlike the rest of the contract. + static Future setDeviceToken(String token) => + _send('PUT', '/miler/device-token', body: {'device_token': token}); + + // ═══════════════════════════════════════════════════════════════════════ + // LOCATION & AVAILABILITY + // ═══════════════════════════════════════════════════════════════════════ + + /// Redis only — a SET plus a GEOADD into `milers:locations`, which is the + /// index dispatch searches. Numbers here, not strings; this is not telemetry. + static Future pushLocation({ + required double latitude, + required double longitude, + 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, + }); + + /// 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, + }); + + /// Note `Break`, not `On_Break` — the obvious guess is the wrong one. + static const List availabilityStatuses = [ + 'Offline', + 'Available', + 'Assigned', + 'On_Pickup', + 'At_Customer', + 'Picked_Up', + 'On_Delivery', + 'Break', + 'Blocked', + ]; + + // ═══════════════════════════════════════════════════════════════════════ + // DUTY & BREAKS + // + // Ordering is enforced server-side: starting duty twice is an error, and a + // break without duty is an error. Callers should treat those as state to + // 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, + }); + + static Future endDuty() => _send('PUT', '/miler/duty/end'); + + 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 endBreak() => _send('PUT', '/miler/breaks/end'); + + // ═══════════════════════════════════════════════════════════════════════ + // ASSIGNMENTS + // ═══════════════════════════════════════════════════════════════════════ + + static Future assignments() => _send('GET', '/miler/assignments'); + + static Future assignment(Object id) => + _send('GET', '/miler/assignments/$id'); + + /// 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: {}); + + /// 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 + /// request against it, so neither source is confirmed. + static Future rejectAssignment( + Object assignmentId, { + required String reason, + }) => _send( + 'POST', + '/miler/assignments/$assignmentId/reject', + query: {'reason': reason}, + body: {'reason': reason}, + ); + + // ═══════════════════════════════════════════════════════════════════════ + // THE PICKUP FLOW — in order, all keyed on bookingid + // ═══════════════════════════════════════════════════════════════════════ + + /// Step 1. + static Future reached( + Object bookingId, { + double? lat, + double? lon, + }) => _send('POST', '/miler/bookings/$bookingId/reached', body: { + if (lat != null) 'latitude': lat, + if (lon != null) 'longitude': lon, + }); + + /// Step 2. The dimensions here are what `pickup-complete` recomputes + /// chargeable weight from, so this is the one moment the parcel's measured + /// size can be recorded at all. + static Future submitParcels( + Object bookingId, + List parcels, + ) => _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( + Object bookingId, { + required double amount, + required String paymentMode, + String? transactionRef, + }) => _send('POST', '/miler/bookings/$bookingId/payment', body: { + 'amount': amount, + 'paymentmode': paymentMode, + 'transactionref': transactionRef ?? '', + }); + + static const List paymentModes = ['Cash', 'UPI', 'Card', 'Wallet']; + + /// Step 4, and the pivot of the whole flow: it converts the booking into a + /// consignment, recomputes chargeable weight from the dimensions submitted in + /// 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. + 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, + }); + + /// Query strings, not a body — the handler reads `c.Query`. + static Future vehicleRequired( + Object bookingId, { + required String type, + required String reason, + }) => _send( + 'POST', + '/miler/bookings/$bookingId/vehicle-required', + query: {'type': type, 'reason': reason}, + ); + + /// Refused once the booking is picked up. + static Future cancelBooking( + Object bookingId, { + required String reason, + }) => _send('POST', '/miler/bookings/$bookingId/cancel', body: { + 'reason': reason, + }); + + // ═══════════════════════════════════════════════════════════════════════ + // DELIVERY + // ═══════════════════════════════════════════════════════════════════════ + + /// 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. + /// + /// `lat`/`lon` must be the actual delivery point: the server computes + /// `riderkms` from the pickup coords by haversine and writes it onto the + /// earnings record with `ridercharges`. Passing the pickup coords here would + /// silently zero the rider's distance for that leg. + static Future deliver( + Object consignmentId, { + required String deliveredToName, + String? otp, + String? photoUrl, + 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, + }); + + /// Bumps `attemptcount` rather than failing the consignment — a skip is a + /// return visit, not an outcome. + static Future skipConsignment( + Object consignmentId, { + 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, + }); + + // ═══════════════════════════════════════════════════════════════════════ + // 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, + }); + + /// `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, + }); + + // ═══════════════════════════════════════════════════════════════════════ + // TELEMETRY — Redis-backed, high frequency + // + // Redis is never the system of record here: a flush loses telemetry, not + // business state. So every call in this section is best-effort and must + // never block the rider. + // ═══════════════════════════════════════════════════════════════════════ + + /// One `MilerLog`. Every numeric field goes as a **string** — sending real + /// numbers fails to parse server-side. + /// + /// Deliberately takes no `userid`: identity comes from the token, and a body + /// `userid` is how one rider's GPS could once be written under another's. + static Future postLog({ + required double latitude, + required double longitude, + String? status, + Object? orderId, + double? speed, + double? heading, + double? accuracy, + int? battery, + bool isCharging = false, + String? connection, + 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, + }); + + static Future getLogs() => _send('GET', '/miler/logs'); + + static Future postStatus(String status) => + _send('POST', '/miler/status', body: {'status': status}); + + static Future getStatus() => _send('GET', '/miler/status'); + + /// A JSON **array**, even for one entry — the handler decodes a list. + static Future postConsignmentLogs( + List entries, + ) => _send( + 'POST', + '/miler/consignments/logs', + body: [for (final e in entries) e.toJson()], + ); + + static Future consignmentLogs(Object consignmentId) => + _send('GET', '/miler/consignments/logs/$consignmentId'); + + /// Must be your own userid; anyone else's is rejected. + static Future userLogs(Object userId) => + _send('GET', '/miler/consignments/userlogs/$userId'); + + // ═══════════════════════════════════════════════════════════════════════ + // NOTIFICATIONS & SUPPORT + // ═══════════════════════════════════════════════════════════════════════ + + /// Synthesized fresh from `BookingAssignment` rows on every call, and `id` is + /// just the array index — so an id is only valid until the next GET. + static Future notifications() => + _send('GET', '/miler/notifications'); + + /// **A stub.** Returns success without persisting anything, because there is + /// no notifications table with read state. Read state cannot survive a + /// refresh until that table exists, so nothing may depend on it. + static Future markNotificationRead(Object id) => + _send('PATCH', '/miler/notifications/$id/read'); + + static Future createSupportTicket({ + required String subject, + required String description, + }) => _send('POST', '/miler/support', body: { + 'subject': subject, + 'description': description, + }); + + static Future supportTickets() => _send('GET', '/miler/support'); + + // ═══════════════════════════════════════════════════════════════════════ + + /// What the rider app needs and the backend does not yet expose. Kept here + /// rather than scattered as `logGap` calls so there is one list to hand the + /// backend team. See the write-up in ABOUT_MILER.md §8. + static const List missingFromBackend = [ + 'per-stop type (pickup | delivery) and step ordering on GET /miler/bookings', + 'COD amount on the booking object', + 'a booking-level resume after skip (skip lives on the consignment only)', + 'a real notifications table with read state', + 'anything that writes bonuspoints', + 'cancelled / total counts on GET /miler/earnings', + ]; +} + +/// One parcel measured at the door, for `POST /miler/bookings/:id/parcel`. +/// +/// Dimensions are centimetres and weight is kilograms. They are what +/// `pickup-complete` recomputes chargeable weight from, so a parcel submitted +/// without them bills on the booked figure rather than the real one. +class ParcelEntry { + final Object? parcelId; + final double weight; + final double length; + final double width; + final double height; + + const ParcelEntry({ + this.parcelId, + required this.weight, + this.length = 0, + this.width = 0, + this.height = 0, + }); + + Map toJson() => { + if (parcelId != null) 'parcel_id': parcelId, + 'weight': weight, + 'length': length, + 'width': width, + 'height': height, + }; +} + +/// One `ConsignmentLog`. Numeric fields are strings, as with [MilerApi.postLog]. +class ConsignmentLogEntry { + final Object consignmentId; + final double latitude; + final double longitude; + final String? status; + final double? speed; + final double? heading; + final int? battery; + final String remarks; + final bool isBackground; + final DateTime? at; + + const ConsignmentLogEntry({ + required this.consignmentId, + required this.latitude, + required this.longitude, + this.status, + this.speed, + this.heading, + this.battery, + this.remarks = '', + this.isBackground = false, + this.at, + }); + + Map toJson() => { + 'consignmentid': consignmentId, + 'logdate': MilerApi.logStamp(at), + 'latitude': latitude.toString(), + 'longitude': longitude.toString(), + if (speed != null) 'speed': speed.toString(), + if (heading != null) 'heading': heading.toString(), + if (status != null) 'status': status, + 'remarks': remarks, + if (battery != null) 'battery': battery.toString(), + 'is_background': isBackground, + }; +} + +/// The result of one call. +/// +/// [ok] folds two things the transport keeps apart: a 2xx status *and* an +/// envelope that did not say `success: false`. A 200 carrying `success: false` +/// is a failure, and treating it as one at the boundary is what stops it being +/// re-checked, inconsistently, at every call site. +class ApiResult { + final bool ok; + final int status; + + /// The envelope's `data`, or the whole body when there is no `data` key. + final dynamic data; + + /// The undecorated decoded body — needed for `verify-pin`, whose payload sits + /// under `user` rather than `data`. + final dynamic raw; + + final String message; + + const ApiResult({ + required this.ok, + required this.status, + this.data, + this.raw, + this.message = '', + }); + + /// [data] as a map, or an empty one. + 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: […]}`. + List get list { + dynamic d = data; + if (d is List) return d; + if (d is Map) { + for (final k in const ['items', 'bookings', 'data', 'details', 'logs']) { + if (d[k] is List) return d[k] as List; + } + } + return const []; + } + + /// True when the failure is an auth failure — the session is gone and the + /// rider has to sign in again, which is a different recovery from a retry. + bool get isUnauthorized => status == 401 || status == 403; +} diff --git a/lib/data/rider_tier.dart b/lib/data/rider_tier.dart new file mode 100644 index 0000000..80bca5d --- /dev/null +++ b/lib/data/rider_tier.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; + +import 'package:miler/views/helpers/constants/Colorconstants.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// RIDER STATUS — the tier ladder the Rewards page is built around +/// +/// Modelled on Uber Pro, which is the version of this a rider is most likely to +/// have already used: four named statuses, points earned per completed job over +/// a fixed reward period, and quality standards that gate the higher tiers +/// regardless of volume. Every loyalty-UX source says the same three things +/// belong on the screen before anything else — where you stand, what you have, +/// and exactly how far the next step is — so those are the three this model has +/// to be able to answer. +/// +/// ── What is real here and what the business still owns ── +/// +/// The *points* are real: the backend already awards them per stop closed +/// inside its ETA, scaled by the distance ridden, and `RewardsController` reads +/// that total. The *standards* are real: on-time and on-route are measured per +/// stop (see `StopCompliance`). +/// +/// The **thresholds below are placeholders**, and so is the idea that a tier +/// unlocks anything. Where the bar sits, and what a rider gets for clearing it, +/// are commercial decisions belonging to the hub — not to this file. They live +/// in one place, named, so a real number drops straight in. Nothing in the UI +/// invents a perk: the page states status, progress and standards, and says +/// plainly that the hub sets what each status is worth. +/// ───────────────────────────────────────────────────────────────────────── +enum RiderTier { blue, silver, gold, platinum } + +/// Points needed to reach each tier within one reward period. +/// +/// PLACEHOLDER — replace with the hub's real ladder. Kept as a single map so +/// the whole ladder can be swapped without touching the page. +const Map kTierThresholds = { + RiderTier.blue: 0, + RiderTier.silver: 150, + RiderTier.gold: 400, + RiderTier.platinum: 800, +}; + +/// The share of measured stops that must clear a check for the status to hold. +/// +/// PLACEHOLDER, same as above. Uber gates its tiers on acceptance and +/// cancellation rates; the equivalents here are the two the hub actually +/// measures — did he make the ETA, did he ride the route. +const int kStandardTarget = 80; + +extension RiderTierUi on RiderTier { + String get label => switch (this) { + RiderTier.blue => 'Blue', + RiderTier.silver => 'Silver', + RiderTier.gold => 'Gold', + RiderTier.platinum => 'Platinum', + }; + + /// The tier above this one, or null at the top. + RiderTier? get next => switch (this) { + RiderTier.blue => RiderTier.silver, + RiderTier.silver => RiderTier.gold, + RiderTier.gold => RiderTier.platinum, + RiderTier.platinum => null, + }; + + 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, + }; + + /// Colour of the badge, not of the page. The card behind it stays brand + /// maroon at every tier — a status screen that changes colour scheme as the + /// rider moves up stops looking like the same product. + Color get accent => switch (this) { + RiderTier.blue => const Color(0xFF7FB3D5), + RiderTier.silver => const Color(0xFFC0C6CC), + RiderTier.gold => ColorConstants.rewardAmber, + RiderTier.platinum => const Color(0xFFB9F2FF), + }; +} + +/// Where a rider stands this period, and how far the next step is. +class TierStanding { + final int points; + final RiderTier tier; + + const TierStanding({required this.points, required this.tier}); + + static TierStanding of(int points) { + var reached = RiderTier.blue; + for (final tier in RiderTier.values) { + if (points >= tier.threshold) reached = tier; + } + return TierStanding(points: points, tier: reached); + } + + RiderTier? get nextTier => tier.next; + + /// Points still needed for the next status. Null at the top. + int? get pointsToNext { + final next = nextTier; + if (next == null) return null; + final gap = next.threshold - points; + return gap <= 0 ? 0 : gap; + } + + /// 0–1 across the current band, for the progress bar. + /// + /// Measured from *this* tier's threshold rather than from zero: a rider one + /// point into Gold should see an empty bar to Platinum, not a bar that is + /// already half full because he has a big lifetime number. + double get progressToNext { + final next = nextTier; + if (next == null) return 1; + final floor = tier.threshold; + final span = next.threshold - floor; + if (span <= 0) return 1; + return ((points - floor) / span).clamp(0.0, 1.0); + } +} + +/// The reward period: the calendar month. +/// +/// Uber runs three-month periods. A month is the right length here because a +/// miler's shift patterns and the hub's rosters are monthly, and because a +/// three-month bar is one a rider cannot move in a day — the whole point of +/// showing progress is that today's work visibly changes it. +class RewardPeriod { + final DateTime start; + final DateTime end; + + const RewardPeriod({required this.start, required this.end}); + + static RewardPeriod current([DateTime? now]) { + final t = now ?? DateTime.now(); + return RewardPeriod( + start: DateTime(t.year, t.month, 1), + // Day zero of next month is the last day of this one. + end: DateTime(t.year, t.month + 1, 0), + ); + } + + static const List _months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + String get label => '${_months[start.month - 1]} ${start.year}'; + + /// Whole days left, today included. Never negative. + int daysLeft([DateTime? now]) { + final t = now ?? DateTime.now(); + final today = DateTime(t.year, t.month, t.day); + final last = DateTime(end.year, end.month, end.day); + final diff = last.difference(today).inDays + 1; + return diff < 0 ? 0 : diff; + } +} diff --git a/lib/data/stop_compliance.dart b/lib/data/stop_compliance.dart new file mode 100644 index 0000000..3b304e0 --- /dev/null +++ b/lib/data/stop_compliance.dart @@ -0,0 +1,210 @@ +/// ───────────────────────────────────────────────────────────────────────── +/// STOP COMPLIANCE — did the rider make the ETA, and did he take the route? +/// +/// Both facts were already being computed and then thrown away. The bonus-point +/// rule in `PickupsController.updatePickedupStatus` is literally "was this +/// closed before `eta_endtime`", and the distance travelled to the stop is +/// measured to bill rider charges. Neither ever reached the rider: he was paid +/// (or not) for punctuality he could not see, and "did I go the way the hub +/// routed me" had no answer anywhere in the app. +/// +/// This is that pair, stamped onto the stop when it is completed and read back +/// on the Activity tab and in Rewards. +/// +/// ── Why it is stamped and not recomputed ── +/// +/// The inputs are gone by the time anyone looks: the ETA deadline lives in a +/// SharedPreferences key that the next stop overwrites, and the distance is +/// measured from the previous stop's location, which has also moved on. The +/// only moment both are true is the moment of completion, so that is where the +/// record is written. +/// +/// ── Unknown is a real answer ── +/// +/// A hub that assigns no ETA, a stop whose GPS never resolved: those are not +/// failures by the rider and are never shown as one. Every field is nullable +/// and the UI says "not measured" rather than inventing a verdict — a +/// compliance score that quietly counts unknowns as misses is a score that +/// punishes riders for the back office's gaps. +/// ───────────────────────────────────────────────────────────────────────── +library; + +/// How far past the planned distance still counts as "took the route". +/// +/// A route figure is a straight-line-ish estimate from the hub's planner, and a +/// rider obeys one-ways, diversions and closed roads it knows nothing about. A +/// quarter over plus half a kilometre of slack is the difference between "took +/// a detour" and "went somewhere else" — tight enough to catch a genuine +/// wander, loose enough that ordinary traffic never trips it. +const double kRouteTolerance = 1.25; +const double kRouteGraceKm = 0.5; + +class StopCompliance { + /// Closed before the ETA deadline. Null when no ETA was ever set. + final bool? onTime; + + /// How late, when [onTime] is false. + final Duration? lateBy; + + /// Distance actually ridden to this stop, and what the hub planned. + final double? actualKm; + final double? plannedKm; + + const StopCompliance({ + this.onTime, + this.lateBy, + this.actualKm, + this.plannedKm, + }); + + static const StopCompliance unknown = StopCompliance(); + + /// Took the assigned route, within [kRouteTolerance]. Null when either + /// distance is missing — a planned figure of zero is missing, not perfect. + bool? get onRoute { + final actual = actualKm; + final planned = plannedKm; + if (actual == null || planned == null || planned <= 0 || actual <= 0) { + return null; + } + return actual <= (planned * kRouteTolerance) + kRouteGraceKm; + } + + /// Kilometres beyond what the route allowed for. Null unless [onRoute] is + /// false — there is no "negative detour" worth reporting. + double? get extraKm { + if (onRoute != false) return null; + return (actualKm! - plannedKm!).clamp(0, double.infinity).toDouble(); + } + + /// True only when both halves are known and both are met. This is the one + /// that earns the reward. + bool get isClean => onTime == true && onRoute == true; + + /// Nothing was measurable for this stop. + bool get isUnknown => onTime == null && onRoute == null; + + Map toJson() => { + if (onTime != null) 'ontime': onTime, + if (lateBy != null) 'latebyseconds': lateBy!.inSeconds, + if (actualKm != null) 'actualkm': actualKm, + if (plannedKm != null) 'plannedkm': plannedKm, + }; + + /// Reads the compliance of a finished stop. + /// + /// Prefers the stamp written at completion; falls back to whatever the + /// backend's own row carries, so a stop completed before this existed — or on + /// another device — still says what it can. + static StopCompliance of(Map stop) { + final raw = stop['compliance']; + final Map m = raw is Map + ? Map.from(raw) + : const {}; + + final bool? onTime = _bool(m['ontime']); + final int? lateSeconds = _int(m['latebyseconds']); + + return StopCompliance( + onTime: onTime, + lateBy: lateSeconds == null ? null : Duration(seconds: lateSeconds), + // `riderkms` is the backend's own name for the distance ridden, so a row + // that came straight from the API reads without a stamp. + actualKm: _double(m['actualkm']) ?? _double(stop['riderkms']), + plannedKm: _double(m['plannedkm']) ?? _double(stop['kms']), + ); + } + + static bool? _bool(dynamic v) { + if (v is bool) return v; + if (v is num) return v != 0; + final s = v?.toString().toLowerCase().trim(); + if (s == 'true' || s == '1') return true; + if (s == 'false' || s == '0') return false; + return null; + } + + static int? _int(dynamic v) { + if (v is num) return v.toInt(); + return int.tryParse(v?.toString().trim() ?? ''); + } + + static double? _double(dynamic v) { + if (v == null) return null; + if (v is num) return v.toDouble(); + final parsed = double.tryParse( + v.toString().replaceAll(RegExp(r'[^0-9.\-]'), ''), + ); + if (parsed == null || parsed == 0) return null; + return parsed; + } +} + +/// The day's compliance, for the Rewards page. +class ComplianceSummary { + /// Stops that could be judged at all — the denominator. + final int measured; + final int onTime; + final int onRoute; + + /// Stops where both held. This is what the reward is paid on. + final int clean; + + /// Finished stops with nothing measurable, kept separate so they are never + /// silently counted as misses. + final int unmeasured; + + const ComplianceSummary({ + required this.measured, + required this.onTime, + required this.onRoute, + required this.clean, + required this.unmeasured, + }); + + static const ComplianceSummary empty = ComplianceSummary( + measured: 0, + onTime: 0, + onRoute: 0, + clean: 0, + unmeasured: 0, + ); + + int get onTimePercent => + measured == 0 ? 0 : ((onTime / measured) * 100).round(); + int get onRoutePercent => + measured == 0 ? 0 : ((onRoute / measured) * 100).round(); + int get cleanPercent => + measured == 0 ? 0 : ((clean / measured) * 100).round(); + + /// Points earned: one per stop that made both its ETA and its route. + /// + /// Deliberately a count and not a percentage — a rider who did four clean + /// stops out of four has not earned the same as one who did twelve, and a + /// percentage would say he had. + int get points => clean; + + static ComplianceSummary from(Iterable> stops) { + int measured = 0, onTime = 0, onRoute = 0, clean = 0, unmeasured = 0; + + for (final stop in stops) { + final c = StopCompliance.of(stop); + if (c.isUnknown) { + unmeasured++; + continue; + } + measured++; + if (c.onTime == true) onTime++; + if (c.onRoute == true) onRoute++; + if (c.isClean) clean++; + } + + return ComplianceSummary( + measured: measured, + onTime: onTime, + onRoute: onRoute, + clean: clean, + unmeasured: unmeasured, + ); + } +} diff --git a/lib/helpers/app_bootstrap.dart b/lib/helpers/app_bootstrap.dart new file mode 100644 index 0000000..63220fd --- /dev/null +++ b/lib/helpers/app_bootstrap.dart @@ -0,0 +1,128 @@ +import 'package:flutter/material.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:get/get.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +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'; + +/// Decides which screen the app opens on, and nothing else. +/// +/// This replaces the old animated splash screen. The splash held two very +/// different jobs: a four-second brand animation, and the routing rules that +/// decide login vs. intro vs. home. Only the second one is load-bearing. +/// +/// The four seconds were pure cost for a rider who opens this app 30–50 times +/// a shift — the native launch image already covers the cold-start gap, so the +/// animation was time added on top of it, every single time. Removing it is +/// the single biggest latency win on the whole flow. +/// +/// What survives is the routing, unchanged in behaviour: +/// • app version changed since last run → force re-verification (MPIN if the +/// rider was signed in, otherwise Sign In) +/// • signed in + on duty → straight to the dashboard +/// • signed in + off duty → the go-on-duty banner +/// • signed out, intro already seen → Sign In +/// • first ever launch → Introscreen +class AppBootstrap extends StatefulWidget { + const AppBootstrap({super.key}); + + @override + State createState() => _AppBootstrapState(); +} + +class _AppBootstrapState extends State { + bool _routed = false; + + @override + void initState() { + super.initState(); + // Route on the first frame — GetX needs a built navigator before `offAll`. + WidgetsBinding.instance.addPostFrameCallback((_) => _resolveStartScreen()); + } + + Future _resolveStartScreen() async { + if (_routed) return; + _routed = true; + + final prefs = await SharedPreferences.getInstance(); + final bool isLoggedOut = prefs.getBool('logged_out') ?? false; + final bool hasSeenIntro = prefs.getBool('has_seen_intro') ?? false; + final int? savedUserId = prefs.getInt('userid'); + + // ── App update → force re-verification ── + try { + final packageInfo = await PackageInfo.fromPlatform(); + final currentVersion = packageInfo.version; + final lastRunVersion = prefs.getString('last_run_version'); + + if (lastRunVersion != null && lastRunVersion != currentVersion) { + debugPrint( + '[BOOTSTRAP] App update detected: $lastRunVersion -> $currentVersion. ' + 'Forcing re-verification.', + ); + + final String? savedPhone = prefs.getString('contactno'); + await prefs.setString('last_run_version', currentVersion); + + if (savedUserId != null && + savedPhone != null && + savedPhone.isNotEmpty) { + debugPrint( + '[BOOTSTRAP] User was logged in. Redirecting to MPIN page.', + ); + + final auth = Get.put(AuthController()); + auth.currentPhone = savedPhone; + + await prefs.remove('userid'); + await prefs.remove('partnerid'); + await prefs.remove('onduty'); + await prefs.setBool('logged_out', true); + + if (mounted) Get.offAll(() => Mpin()); + return; + } + + debugPrint( + '[BOOTSTRAP] User was not logged in. Redirecting to Sign In.', + ); + if (mounted) Get.offAll(() => const SignIn()); + return; + } + await prefs.setString('last_run_version', currentVersion); + } catch (e) { + debugPrint('[BOOTSTRAP] Version check error: $e'); + } + + if (!mounted) return; + + // ── Normal launch ── + // + // A signed-in rider lands on the tabs whether or not he is on duty. The + // duty gate that used to sit here made "open the app" and "start working" + // the same decision, so a rider checking his earnings between shifts had to + // 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()); + } else { + Get.offAll(() => hasSeenIntro ? const SignIn() : Introscreen()); + } + } + + @override + Widget build(BuildContext context) { + // Held for the few milliseconds the prefs read takes. Plain white so it is + // indistinguishable from the native launch image — the rider should never + // perceive a screen here at all. + return Scaffold( + backgroundColor: ColorConstants.pureSurface, + body: SizedBox.expand(), + ); + } +} diff --git a/lib/helpers/http_overrides.dart b/lib/helpers/http_overrides.dart new file mode 100644 index 0000000..36ccf39 --- /dev/null +++ b/lib/helpers/http_overrides.dart @@ -0,0 +1,10 @@ +import 'dart:io'; + +class MyHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context) + ..badCertificateCallback = + (X509Certificate cert, String host, int port) => true; + } +} diff --git a/lib/helpers/miler_router.dart b/lib/helpers/miler_router.dart new file mode 100644 index 0000000..2e3073a --- /dev/null +++ b/lib/helpers/miler_router.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart' show LatLng; + +// ── Routing ───────────────────────────────────────────────────────────────── + +/// A driving route between two points, from OSRM's public router. +/// +/// Replaces the Directions API call, which needed the revoked key. Same shape +/// of answer — an ordered list of coordinates — so the callers' route drawing, +/// distance table and camera fitting are untouched. +class MilerRouter { + static const String _host = 'https://router.project-osrm.org'; + + /// Sent with every request. OSRM's demo server, like OSM's tile server, + /// blocks anonymous clients first. + static const String _agent = 'com.doormile.partner'; + + /// The road geometry from [start] to [end], or an empty list. + /// + /// Never throws: a route screen with no line is a degraded screen, but a + /// route screen that crashed is a rider standing in the street with nothing. + /// Callers fall back to the direct line on an empty answer. + static Future> route(LatLng start, LatLng end) async { + final uri = Uri.parse( + '$_host/route/v1/driving/' + '${start.longitude},${start.latitude};${end.longitude},${end.latitude}' + '?overview=full&geometries=geojson', + ); + + try { + final res = await http + .get(uri, headers: const {'User-Agent': _agent}) + .timeout(const Duration(seconds: 8)); + if (res.statusCode != 200) { + debugPrint( + '[MAP] OSRM ${res.statusCode} — falling back to a direct line.', + ); + return const []; + } + + final body = jsonDecode(res.body) as Map; + if (body['code'] != 'Ok') { + debugPrint('[MAP] OSRM said ${body['code']} — direct line instead.'); + return const []; + } + + final routes = body['routes'] as List?; + if (routes == null || routes.isEmpty) return const []; + + final coords = + (routes.first as Map)['geometry']['coordinates'] + as List; + + // GeoJSON is [longitude, latitude] — the opposite order to everything + // else in this app, and a silent way to end up routing through the sea. + return [ + for (final c in coords) + LatLng((c[1] as num).toDouble(), (c[0] as num).toDouble()), + ]; + } catch (e) { + debugPrint('[MAP] OSRM unreachable ($e) — direct line instead.'); + return const []; + } + } + + /// Road distance in metres between two points, or null when the router + /// cannot answer. + /// + /// Replaces a direct call to the Google Directions API in + /// `PickupsController`, which was reading the same revoked key and therefore + /// silently falling back to straight-line distance on every trip — the number + /// riders are *paid* on. + static Future roadDistanceMetres(LatLng start, LatLng end) async { + final uri = Uri.parse( + '$_host/route/v1/driving/' + '${start.longitude},${start.latitude};${end.longitude},${end.latitude}' + '?overview=false', + ); + try { + final res = await http + .get(uri, headers: const {'User-Agent': _agent}) + .timeout(const Duration(seconds: 6)); + if (res.statusCode != 200) return null; + final body = jsonDecode(res.body) as Map; + if (body['code'] != 'Ok') return null; + final routes = body['routes'] as List?; + if (routes == null || routes.isEmpty) return null; + final d = (routes.first as Map)['distance']; + return d is num ? d.toDouble() : null; + } catch (e) { + debugPrint('[ROUTE] OSRM distance unavailable ($e)'); + return null; + } + } +} diff --git a/lib/helpers/poll_policy.dart b/lib/helpers/poll_policy.dart new file mode 100644 index 0000000..70895c3 --- /dev/null +++ b/lib/helpers/poll_policy.dart @@ -0,0 +1,39 @@ +/// ─────────────────────────────────────────────────────────────────────────── +/// HOW OFTEN THE APP ASKS THE SERVER ANYTHING +/// +/// One policy, because the two screens that poll had invented their own and +/// neither was tied to what the rider was actually doing. +/// +/// • Bookings polled every **3 seconds, always** — on duty, off duty, phone in +/// pocket, screen off, nothing running. Twelve hundred requests an hour for +/// a queue that changes a few times a shift. +/// • Home polled every 5s on duty and 20s off, which at least noticed duty, +/// but not whether there was any live work to watch. +/// +/// Neither stopped when the app went to the background. A rider on duty with +/// the phone in his pocket was running a request every three seconds, all +/// shift, on his own battery and his own data pack. That is not a technical +/// detail — it is the app quietly costing him money and charge for nothing. +/// +/// The rule now: **poll fast only while something is actually happening.** +/// +/// live stop in progress → 5s (the rider is working it; staleness shows) +/// on duty, nothing live → 30s (waiting for the hub to assign) +/// off duty → 60s (nothing can arrive; this is just a heartbeat) +/// app backgrounded → stop (see [PollLifecycle]) +/// +/// A stop the rider is working is still watched at the old speed. Everything +/// else drops by a factor of ten. +/// ─────────────────────────────────────────────────────────────────────────── +Duration pollInterval({required bool hasLiveWork, required bool onDuty}) { + if (hasLiveWork) return const Duration(seconds: 5); + if (onDuty) return const Duration(seconds: 30); + return const Duration(seconds: 60); +} + +/// Polling must also stop when the app is not in front of the rider: the work +/// it does then can never be seen, because nobody is looking at the screen. +/// Both polling screens implement that in their own +/// `didChangeAppLifecycleState` — cancel on pause, restart plus one immediate +/// catch-up fetch on resume, so the rider never comes back to a screen showing +/// what was true a minute ago. diff --git a/lib/helpers/shift_end_alarm.dart b/lib/helpers/shift_end_alarm.dart new file mode 100644 index 0000000..02b7d91 --- /dev/null +++ b/lib/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/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 BackgroundCollectionLog.checkShiftEnd(); + + debugPrint('[SHIFT_END_ALARM] ✅ Break log created successfully'); + } catch (e) { + debugPrint('[SHIFT_END_ALARM] ❌ Error handling shift end: $e'); + } + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..af80d1a --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,361 @@ +import 'dart:io'; +import 'package:miler/helpers/http_overrides.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:get/get.dart'; +import 'package:miler/helpers/app_bootstrap.dart'; +import 'package:miler/views/offline/offline_banner.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/controllers/profile_controller.dart'; +import 'package:miler/controllers/riderlog.dart'; +import 'package:miler/controllers/pickup.dart'; +import 'package:miler/controllers/logcontroller.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:miler/providers/notifications/notificationservce.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:new_version_plus/new_version_plus.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +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/views/helpers/constants/app_theme.dart'; +import 'package:miler/views/helpers/widgets/page_transitions.dart'; + +import 'views/offline/offline_page.dart'; + +// Background message handler +@pragma('vm:entry-point') +Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { + await Firebase.initializeApp(); + await NotificationServce.display(message); +} + +String currentVersion = ''; +String storeVersion = ''; +bool updateRequired = false; + +Future getAppVersion() async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + + String version = packageInfo.version; + currentVersion = version; + prefs.setString('CurrentVersion', currentVersion); + debugPrint('Current version from main: $currentVersion'); +} + +Future checkForUpdate(BuildContext context) async { + // Version check is handled in splash screen +} + +Future recheckVersion() async { + final newVersion = NewVersionPlus( + iOSId: '284882215', + androidId: "com.doormile.partner", + ); + + try { + final status = await newVersion.getVersionStatus(); + if (status != null) { + debugPrint( + "Recheck - Current: ${status.localVersion}, Store: ${status.storeVersion}", + ); + return status.canUpdate; + } + } catch (e) { + debugPrint("Error rechecking version: $e"); + } + + return false; +} + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + HttpOverrides.global = MyHttpOverrides(); + + try { + await Firebase.initializeApp(); + FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler); + } catch (e) { + debugPrint('[FIREBASE] Initialization failed: $e'); + // Firebase may not be configured for this platform (e.g. iOS simulator without GoogleService-Info.plist) + } + + // 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(); + Get.put(RiderLogController(), permanent: true); + Get.put(PickupController(), permanent: true); + final logController = Get.put(LogController(), permanent: true); + + await logController.startLogging(); + + _applySystemChrome(); + + runApp(const _RootApp()); +} + +/// Paints the status and navigation bars to match the page behind them. +void _applySystemChrome() { + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: ColorConstants.surface, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + systemNavigationBarColor: ColorConstants.surface, + systemNavigationBarIconBrightness: Brightness.dark, + ), + ); +} + +class _RootApp extends StatefulWidget { + const _RootApp(); + + @override + State<_RootApp> createState() => _RootAppState(); +} + +class _RootAppState extends State<_RootApp> with WidgetsBindingObserver { + List _connectionStatus = [ConnectivityResult.none]; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + Connectivity().onConnectivityChanged.listen((result) { + if (mounted) { + setState(() { + _connectionStatus = result; + }); + } + }); + + Connectivity().checkConnectivity().then((result) { + if (mounted) { + setState(() { + _connectionStatus = result; + }); + } + }); + + // ✅ Check if shift ended while app was killed OR if shift end time has already passed + _checkShiftEndOnStartup(); + + // Decides banner-vs-blocker only. See [_hasSession]. + _readSessionPresence(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + /// Check if shift ended while app was killed OR if shift end time has already passed + /// This will trigger break log immediately if rider is on duty and shift end time passed + Future _checkShiftEndOnStartup() async { + try { + final prefs = await SharedPreferences.getInstance(); + final int onduty = prefs.getInt('onduty') ?? 0; + + // Only check if rider was on duty + if (onduty != 1) { + debugPrint('[APP_STARTUP] Rider not on duty, skipping shift end check'); + return; + } + + // Check if shift has ended + final String endTimeStr = prefs.getString('endtime') ?? ''; + if (endTimeStr.isEmpty) { + debugPrint('[APP_STARTUP] No endtime found, skipping shift end check'); + return; + } + + debugPrint( + '[APP_STARTUP] Checking shift end - Current time: ${DateTime.now()}, End time: $endTimeStr', + ); + + // Use the same logic as BackgroundCollectionLog.checkShiftEnd() + 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; + + final DateTime endToday = DateTime( + now.year, + now.month, + now.day, + endHour, + endMinute, + endSecond, + ); + + bool isShiftOver = false; + final String startTimeStr = prefs.getString('starttime') ?? ''; + + 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; + + final double startVal = startHour + (startMinute / 60.0); + final double endVal = endHour + (endMinute / 60.0); + + if (startVal > endVal) { + // Overnight shift + final DateTime startToday = DateTime( + now.year, + now.month, + now.day, + startHour, + startMinute, + ); + + if (now.isAfter(endToday) && now.isBefore(startToday)) { + isShiftOver = true; + } + } else { + // Normal day shift + if (now.isAfter(endToday)) { + isShiftOver = true; + } + } + } else { + if (now.isAfter(endToday)) { + isShiftOver = true; + } + } + } else { + if (now.isAfter(endToday)) { + isShiftOver = true; + } + } + + if (isShiftOver) { + debugPrint( + '[APP_STARTUP] ⏰ Shift end time ($endTimeStr) has already passed - creating break log IMMEDIATELY...', + ); + // Call the background service to create break log + await BackgroundCollectionLog.checkShiftEnd(); + debugPrint('[APP_STARTUP] ✅ Break log created (if needed)'); + } else { + debugPrint( + '[APP_STARTUP] Shift end time ($endTimeStr) has not passed yet - scheduling alarm', + ); + // Also reschedule the alarm in case it was cancelled + try { + final String startTime = prefs.getString('starttime') ?? ''; + await ShiftEndAlarm.scheduleAlarm( + endTime: endTimeStr, + startTime: startTime, + ); + } catch (e) { + debugPrint('[APP_STARTUP] Error rescheduling alarm: $e'); + } + } + } catch (e) { + debugPrint('[APP_STARTUP] Error checking shift end: $e'); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + if (state == AppLifecycleState.resumed) { + // When app comes back to foreground, ensure rider logs resume + try { + if (Get.isRegistered()) { + final ctl = Get.find(); + // Fire an immediate rider log (best-effort) + ctl.createLoginNowV2(); + // Ensure the periodic background/foreground loop is running + ctl.startAutoCreateLoginLoop(); + } + } catch (_) { + // Ignore lifecycle errors; app should not crash because of logging + } + } + } + + bool get _isOffline { + return _connectionStatus.isEmpty || + _connectionStatus.every((status) => status == ConnectivityResult.none); + } + + /// Whether there is a signed-in rider whose work is already on the device. + /// + /// Read once, purely to choose between two *presentations* of being offline — + /// it changes no auth behaviour and no routing. [AppBootstrap] still decides + /// where the app actually goes. + bool _hasSession = false; + + Future _readSessionPresence() async { + try { + final prefs = await SharedPreferences.getInstance(); + final loggedOut = prefs.getBool('logged_out') ?? false; + final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + if (mounted) setState(() => _hasSession = !loggedOut && userId != 0); + } catch (_) { + // Unknown → treat as no session, which is the more cautious of the two + // presentations. It only affects which offline UI is shown. + } + } + + @override + Widget build(BuildContext context) { + // ⭐ RESPONSIVE WRAPPER ADDED + return ScreenUtilInit( + designSize: const Size(390, 844), // YOUR BASE SIZE + minTextAdapt: true, + splitScreenMode: true, + builder: (_, __) { + return GetMaterialApp( + debugShowCheckedModeBanner: false, + title: 'Miler partner', + // Smooth, consistent page motion across the whole app (Uber-style + // horizontal slide with a gentle ease) instead of abrupt cuts. + // Screens navigated to by hand use `openScreen`/`openSheet` from + // views/helpers/widgets/page_transitions.dart; this covers the + // whole-app swaps (`Get.offAll`) that don't go through them. + defaultTransition: Transition.cupertino, + transitionDuration: kMilerPushIn, + theme: AppTheme.light, + home: const AppBootstrap(), + + // ── Offline is a strip, not a wall ── + // + // This used to stack an opaque [OfflinePage] over the whole app the + // moment connectivity dropped — so a rider at a doorstep in a + // basement lost the address, the customer, the parcel count and the + // OTP field while holding the parcel. Everything he needs at the door + // was already fetched; the network is required to *report* the stop, + // not to work it. + // + // So a signed-in rider keeps his screen and gets a line of text above + // it. The full-screen page survives only for a cold start with no + // session, where there genuinely is nothing to show him. + builder: (context, child) { + final baseChild = child ?? const SizedBox.shrink(); + + if (_isOffline && !_hasSession) { + return Stack(children: [baseChild, const OfflinePage()]); + } + + return OfflineScope(offline: _isOffline, child: baseChild); + }, + ); + }, + ); + } +} diff --git a/lib/providers/Riderlog/riderlog_provider.dart b/lib/providers/Riderlog/riderlog_provider.dart new file mode 100644 index 0000000..10e52b5 --- /dev/null +++ b/lib/providers/Riderlog/riderlog_provider.dart @@ -0,0 +1,232 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/miler_api.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// DUTY, BREAKS AND THE LOCATION HEARTBEAT +/// +/// ── What was deleted here ── +/// +/// A `_buildSslBypassClient()` that returned an `IOClient` whose +/// `badCertificateCallback` returned `true` for every certificate, pinned a +/// hardcoded IP for `queue.workolik.com`, and hand-rolled its own +/// `SecureSocket` upgrade. It existed to work around a carrier's broken DNS on +/// a backend that is no longer deployed — and it meant every request it carried +/// could be read and rewritten by anything on the path, including a rider's GPS +/// track and his duty state. +/// +/// It is gone along with the rest of the legacy path. `api.doormile.com` is +/// reached over ordinary, verified TLS. +/// +/// ── The class names survive their implementations ── +/// +/// `RiderLogController` drives duty through create/update/get "rider log" +/// calls, which is the legacy shape. Rather than rewrite that controller, these +/// four classes keep their names and map the intent onto the real endpoints: +/// the first log of a session starts duty, later ones are location heartbeats, +/// and an off-duty payload ends it. +/// ───────────────────────────────────────────────────────────────────────── + +String _s(dynamic v) => v == null ? '' : v.toString(); + +double? _d(dynamic v) => double.tryParse(_s(v)); + +/// Legacy `{status, details}` for the duty-state readers. +Map _dutyEnvelope(Map data) { + final bool on = data['onduty'] == true; + final dutylogid = data['dutylogid'] ?? 0; + return { + 'status': true, + 'code': 200, + 'details': { + 'logid': dutylogid, + 'onduty': on ? 1 : 0, + 'login': data['loginat'], + }, + }; +} + +Future?> _startDuty(Map data) async { + final res = await MilerApi.startDuty( + lat: _d(data['latitude']), + lon: _d(data['longitude']), + ); + if (res.ok) { + final id = int.tryParse('${res.map['dutylogid'] ?? 0}') ?? 0; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('logid', id); + await prefs.setInt('logId', id); + await prefs.setInt('dutylogid', id); + return { + 'status': true, + 'code': 200, + '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 + // server disagreeing about state, which the current duty answers. + if (res.status == 400) return _currentDuty(); + return {'status': false, 'code': res.status}; +} + +Future?> _currentDuty() async { + final res = await MilerApi.dutyCurrent(); + if (!res.ok) return {'status': false, 'code': res.status}; + + final on = res.map['onduty'] == true; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('onduty', on ? 1 : 0); + if (on) { + final id = int.tryParse('${res.map['dutylogid'] ?? 0}') ?? 0; + await prefs.setInt('logid', id); + await prefs.setInt('logId', id); + } + return _dutyEnvelope(res.map); +} + +/// The GPS heartbeat, which is two writes with different lifetimes: +/// +/// • `PUT /miler/location` — Redis, and the index dispatch searches. This is +/// the one that decides whether the rider is findable. +/// • `POST /miler/logs` — the telemetry trail, for reconstructing a shift +/// afterwards. +/// +/// Both are best-effort and neither blocks the rider. The location write is +/// reported back because duty state depends on it; the log is fire-and-forget. +Future?> _heartbeat(Map data) async { + final lat = _d(data['latitude']); + final lon = _d(data['longitude']); + if (lat == null || lon == null) return ApiConfig.okEnvelope('no fix'); + + final res = await MilerApi.pushLocation( + latitude: lat, + longitude: lon, + pincode: _s(data['pincode']).isEmpty ? null : _s(data['pincode']), + speed: _d(data['speed']), + heading: _d(data['heading']), + ); + + // Deliberately not awaited into the result: a telemetry failure must never + // look like a location failure, because the caller treats the latter as duty + // state going wrong. + unawaited( + MilerApi.postLog( + latitude: lat, + longitude: lon, + speed: _d(data['speed']), + heading: _d(data['heading']), + accuracy: _d(data['accuracy']), + status: _s(data['status']).isEmpty ? null : _s(data['status']), + orderId: data['orderid'], + battery: int.tryParse(_s(data['battery'])), + isCharging: data['is_charging'] == true, + connection: _s(data['connection']).isEmpty + ? null + : _s(data['connection']), + isBackground: data['is_background'] == true, + ), + ); + + return res.ok ? ApiConfig.okEnvelope() : {'status': false, 'code': res.status}; +} + +/// True when the payload means "go off duty". +bool _isOffDutyIntent(Map data) { + final status = _s(data['status']).toLowerCase(); + final onduty = data['onduty']; + final bool ondutyZero = onduty == 0 || _s(onduty) == '0'; + return status.contains('offline') || + status.contains('logout') || + data.containsKey('logout') || + (data.containsKey('onduty') && ondutyZero); +} + +class CreateRiderLogProvider { + /// The first call of a session starts duty; every later one is a heartbeat. + Future?> createRiderLog( + Map data, + ) async { + final prefs = await SharedPreferences.getInstance(); + final int existing = prefs.getInt('dutylogid') ?? 0; + if (existing <= 0) return _startDuty(data); + return _heartbeat(data); + } +} + +class UpdateRiderLogProvider { + Future?> updateRiderLog( + Map data, + ) async { + if (_isOffDutyIntent(data)) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('dutylogid', 0); + final res = await MilerApi.endDuty(); + if (res.ok) await prefs.setInt('onduty', 0); + return res.ok + ? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true}) + : {'status': false, 'code': res.status}; + } + + final onduty = data['onduty']; + if (onduty == 1 || _s(onduty) == '1') { + final prefs = await SharedPreferences.getInstance(); + if ((prefs.getInt('dutylogid') ?? 0) <= 0) return _startDuty(data); + } + return _heartbeat(data); + } +} + +class GetRiderLogProvider { + Future?> getRiderLog() => _currentDuty(); + + /// There is no rider-count endpoint in the contract, and no screen that needs + /// one — the dashboard figures come from bookings and earnings. Kept as a + /// zero so the one legacy caller does not have to branch. + Future?> getRiderCount() async { + ApiConfig.logGap('getRiderCount', 'No count endpoint in v1.'); + return {'status': true, 'count': 0}; + } +} + +class BreakRiderLogProvider { + Future?> createBreakRiderLog( + Map data, + ) async { + final bt = _s(data['breaktype'] ?? data['reason'] ?? 'Personal'); + final res = await MilerApi.startBreak(bt); + if (res.ok) { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt( + 'breaklogid', + int.tryParse('${res.map['breaklogid'] ?? 0}') ?? 0, + ); + // The availability enum's break value is `Break`, not `On_Break` — the + // obvious guess is the wrong one and is silently rejected. + unawaited(MilerApi.setAvailability('Break')); + return ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true}); + } + return {'status': false, 'code': res.status}; + } + + Future?> updateBreakRiderLog() async { + final res = await MilerApi.endBreak(); + if (res.ok) unawaited(MilerApi.setAvailability('Available')); + return res.ok + ? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true}) + : {'status': false, 'code': res.status}; + } +} + +/// Local `unawaited`, so a fire-and-forget call reads as deliberate rather than +/// as a missing `await`. +void unawaited(Future future) { + future.catchError((Object e) { + debugPrint('[RIDERLOG] background call failed: $e'); + }); +} diff --git a/lib/providers/auth/auth_provider.dart b/lib/providers/auth/auth_provider.dart new file mode 100644 index 0000000..7174ce7 --- /dev/null +++ b/lib/providers/auth/auth_provider.dart @@ -0,0 +1,401 @@ +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'}, + ); + } +} diff --git a/lib/providers/notifications/notificationservce.dart b/lib/providers/notifications/notificationservce.dart new file mode 100644 index 0000000..1a934b0 --- /dev/null +++ b/lib/providers/notifications/notificationservce.dart @@ -0,0 +1,431 @@ +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'; +import 'package:miler/providers/auth/auth_provider.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/constants/design_constants.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 = 'Miler'; + static bool _isPlaying = false; + + static const AndroidNotificationChannel channel = AndroidNotificationChannel( + 'Miler', + 'Miler Notification', + description: 'Channel for Miler 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); + }); + + // FCM rotates tokens independently of our session. The cached token in + // prefs is what login registers with the backend, so without this listener + // a rotation silently stops all new-assignment pushes until the next + // reinstall. Re-cache and re-register on every rotation. + FirebaseMessaging.instance.onTokenRefresh.listen((String token) async { + if (token.isEmpty) return; + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('fcmToken', token); + await AuthProvider().saveDeviceToken(token); + } catch (e) { + debugPrint('[FCM] token refresh handling failed: $e'); + } + }); + } + + // ✅ 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 = 'Miler_$rawName'; + + final custom = AndroidNotificationChannel( + _channelId, + 'Miler Notification', + description: 'Channel for Miler 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, + 'Miler 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, + 'Miler 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, + 'Miler 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'] ?? 'Miler'; + 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, + fontFamily: FontConstants.fontFamily, + ), + ), + if (body.isNotEmpty) Text(body), + ], + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + duration: const Duration(seconds: 3), + ), + ); + } + } else { + await _notificationsPlugin.show( + id, + message.notification?.title ?? message.data['title'] ?? 'Miler', + 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'] ?? 'Miler'; + 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/providers/pickup/pickup_provider.dart b/lib/providers/pickup/pickup_provider.dart new file mode 100644 index 0000000..f857c8c --- /dev/null +++ b/lib/providers/pickup/pickup_provider.dart @@ -0,0 +1,59 @@ +import 'package:flutter/foundation.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/miler_api.dart'; + +/// Reads the rider's bookings. +/// +/// ── What this used to be ── +/// +/// Three near-identical methods — `getPickupQueues`, `getCurrentPickups`, +/// `getPickupQueuesPicked` — each with its own legacy URL, its own +/// `fromdate`/`todate`/`userid` query and its own hand-rolled envelope +/// unwrapping, and each opening with `if (ApiConfig.useNewApi) return +/// _getBookingsNew(...)`. All three legacy paths hit different endpoints on a +/// backend that is no longer deployed, and all three new paths were the same +/// call. What is left is that one call. +/// +/// The three names survive because three call sites read differently — Home +/// wants the queue, Bookings wants the accepted work — even though the request +/// is identical and the filtering happens client-side. Collapsing them into one +/// method is a separate change to the screens, not to this file. +class PickupProvider { + /// `GET /miler/bookings` → the list, mapped into the legacy stop shape the + /// cards read. + /// + /// 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. + 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}'}'); + } + + 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; + } + + /// Everything the hub has put in front of this rider today. + Future> getPickupQueues({String? orderstatus}) => + _bookings(status: orderstatus); + + /// The stops he is working now. + Future> getCurrentPickups() => _bookings(); + + /// The stops he has accepted. + Future> getPickupQueuesPicked() => _bookings(); +} diff --git a/lib/providers/pickuplog/pickuplog_provider.dart b/lib/providers/pickuplog/pickuplog_provider.dart new file mode 100644 index 0000000..49711e2 --- /dev/null +++ b/lib/providers/pickuplog/pickuplog_provider.dart @@ -0,0 +1,299 @@ +import 'package:flutter/foundation.dart'; + +import 'package:miler/data/api_config.dart'; +import 'package:miler/data/assignment_lookup.dart'; +import 'package:miler/data/miler_api.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE PER-STOP WRITE PATH +/// +/// Everything the rider does at a door goes through here. +/// +/// ── What changed ── +/// +/// This file used to carry two complete implementations of every call: a +/// legacy one that spoke to `queue.workolik.com` through a hand-built +/// `IOClient` — one that disabled certificate checking, pinned a hardcoded IP +/// and did its own TLS upgrade to work around a carrier's broken DNS — and a +/// v1 one behind `if (ApiConfig.useNewApi)`. The legacy backend is gone, and +/// with it the reason to ship a client that trusts any certificate presented to +/// it. Both are deleted. +/// +/// ── The four calls that were missing ── +/// +/// The v1 pickup flow is four ordered steps, and only two of them were ever +/// sent. `parcel` and `payment` were collected from the rider at the door and +/// then dropped on the floor: +/// +/// 1. `reached` ✅ was wired +/// 2. `parcel` ❌ the weight and count he typed went nowhere +/// 3. `payment` ❌ the cash he took went nowhere +/// 4. `pickup-complete` ✅ was wired +/// +/// Step 2 is the one that costs money: `pickup-complete` recomputes chargeable +/// weight from those dimensions, so a booking completed without them bills on +/// the customer's estimate rather than on what the rider actually weighed. And +/// step 3 is the rider's own accountability record for cash he is carrying. +/// +/// `skip` and `cancel` were worse than missing — they returned a *fabricated +/// success*, so the app told the rider his skip had registered while nothing +/// left the phone. Both endpoints exist and are now called. +/// ───────────────────────────────────────────────────────────────────────── + +/// Per-stop GPS breadcrumbs, tied to a consignment. +class CreatePickupLogProvider { + /// Was a no-op returning a synthetic success, on the grounds that "there is + /// no audit-log endpoint in v1". There is: `POST /miler/consignments/logs`, + /// which takes a JSON **array** and wants its numbers as strings. + /// + /// Best-effort by design. Telemetry is Redis-backed and is explicitly not the + /// system of record — losing a breadcrumb costs a line on a map, and blocking + /// the rider to retry one would cost him a stop. + 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'] ?? ''}'); + + if (consignmentId == null || lat == null || lon == null) { + // Not enough to place the breadcrumb. Silently skipping is right — this + // fires on a timer and a missing fix is normal, not an error. + return ApiConfig.okEnvelope(); + } + + final res = await MilerApi.postConsignmentLogs([ + ConsignmentLogEntry( + consignmentId: consignmentId, + latitude: lat, + longitude: lon, + status: (data['status'] ?? data['orderstatus'])?.toString(), + speed: double.tryParse('${data['speed'] ?? ''}'), + heading: double.tryParse('${data['heading'] ?? ''}'), + battery: int.tryParse('${data['battery'] ?? ''}'), + remarks: (data['remarks'] ?? '').toString(), + isBackground: data['is_background'] == true, + ), + ]); + return res.ok ? ApiConfig.okEnvelope() : {'status': false}; + } +} + +class UpdatePickupProvider { + /// Maps a legacy `updatepickup` payload onto the v1 route its `orderstatus` + /// means. + /// + /// The legacy payload shape is kept because ~8 call sites in + /// `PickupsController` build it, and rewriting those is a separate change to + /// a file this one has no business touching. What matters is that every + /// branch now ends in a real request or an honest failure — there are no + /// invented successes left. + Future?> updatePickup(Map data) async { + final String status = (data['orderstatus'] ?? '').toString(); + final id = data['pickupid'] ?? data['orderheaderid']; + final String notes = (data['notes'] ?? '').toString(); + + final double? lat = double.tryParse( + '${data['riderslat'] ?? data['pickuplat'] ?? ''}', + ); + final double? lon = double.tryParse( + '${data['riderslon'] ?? data['pickuplong'] ?? ''}', + ); + + Map envelope(ApiResult r) => r.ok + ? ApiConfig.toLegacyEnvelope(r.raw ?? {'success': true}) + : {'status': false, 'code': r.status, 'message': r.message}; + + switch (status) { + case 'accepted': + case 'rejected': + // Keyed on bookingassignmentid, not bookingid — different sequences. + // Sending the booking id makes the backend answer 404 while the rider + // is shown success and the booking is never actually accepted. + final assignmentId = await AssignmentLookup.idForBooking(id); + if (assignmentId == null) { + ApiConfig.logGap( + 'update:$status', + 'No assignment row for booking $id — cannot $status.', + ); + return { + 'status': false, + 'message': 'This stop is no longer available.', + }; + } + AssignmentLookup.invalidate(); + final r = status == 'accepted' + ? await MilerApi.acceptAssignment(assignmentId) + : await MilerApi.rejectAssignment( + assignmentId, + reason: notes.isEmpty ? 'Declined by rider' : notes, + ); + return envelope(r); + + case 'arrived': + return envelope(await MilerApi.reached(id, lat: lat, lon: lon)); + + case 'Picked up': + case 'picked': + return envelope(await MilerApi.pickupComplete(id, lat: lat, lon: lon)); + + case 'delivered': + case 'Delivered': + return envelope( + await MilerApi.deliver( + id, + 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. + otp: (data['otp'] ?? '').toString(), + photoUrl: (data['dropimage'] ?? '').toString(), + receiverSignatureUrl: (data['signature'] ?? '').toString(), + lat: lat, + lon: lon, + ), + ); + + case 'skipped': + // Was a fabricated success — `okEnvelope('skip not supported by + // backend')` — so the rider watched a stop move to "skipped" while + // nothing left the phone and the hub never learned. + return envelope( + await MilerApi.skipConsignment( + id, + reason: notes.isEmpty ? 'Skipped by rider' : notes, + lat: lat, + lon: lon, + ), + ); + + case 'cancelled': + // Also previously fabricated. Refused server-side once the stop is + // picked up, which is correct and now surfaces as a real failure. + return envelope( + await MilerApi.cancelBooking( + id, + reason: notes.isEmpty ? 'Cancelled by rider' : notes, + ), + ); + + case 'active': + // `Pickup_Scheduled` is system-set; there is no rider endpoint for it. + // Nudging availability is the closest true statement the app can make. + return envelope(await MilerApi.setAvailability('On_Pickup')); + + default: + ApiConfig.logGap('update:unknown', 'Unmapped orderstatus="$status".'); + return ApiConfig.okEnvelope(); + } + } + + /// **Step 2** — what the rider actually took, measured at the door. + /// + /// Reads the verification screen's payload directly rather than taking a + /// dozen arguments, because that map is already the record of the door and + /// splitting it would give two places to keep in step. + /// + /// Dimensions are not collected by the app today — the verify screen asks for + /// total weight and a count, not L×W×H per parcel — so they go as zero and + /// the backend bills on weight alone. Collecting them is a UI change waiting + /// on a decision about how much to ask a rider for at a doorstep. + Future?> submitParcels( + Object bookingId, + Map verification, + ) async { + final pickup = verification['pickup']; + if (pickup is! Map) return ApiConfig.okEnvelope('no pickup leg'); + + final int count = int.tryParse('${pickup['collected'] ?? 0}') ?? 0; + final double totalWeight = + double.tryParse('${pickup['weight'] ?? ''}') ?? 0; + + if (count <= 0 || totalWeight <= 0) { + ApiConfig.logGap( + 'submitParcels', + 'booking $bookingId: count=$count weight=$totalWeight — nothing to send.', + ); + return {'status': false, 'message': 'Parcel details are incomplete.'}; + } + + // The rider weighs the consignment, not each box. Splitting the total + // evenly is the only honest distribution available from what he was asked, + // 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 res = await MilerApi.submitParcels(bookingId, parcels); + debugPrint( + '[PARCEL] booking $bookingId: $count parcel(s), ${totalWeight}kg ' + '→ ${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 3** — the money, from the collect-payment screen's payload. + /// + /// Returns a success envelope without calling anything when nothing was + /// collected: a prepaid stop has no payment to record, and posting a zero + /// would be rejected anyway (`amount` must be > 0). + Future?> submitPayment( + Object bookingId, + Map payment, + ) async { + if (payment['paid'] != true) return ApiConfig.okEnvelope('not collected'); + + final double amount = + double.tryParse('${payment['amountCollected'] ?? 0}') ?? 0; + if (amount <= 0) return ApiConfig.okEnvelope('nothing collected'); + + // The screen's own wire names are lowercase; the contract's enum is not. + final String mode = switch ('${payment['method']}'.toLowerCase()) { + 'upi' => 'UPI', + 'card' => 'Card', + 'wallet' => 'Wallet', + _ => 'Cash', + }; + + final res = await MilerApi.submitPayment( + bookingId, + amount: amount, + paymentMode: mode, + transactionRef: (payment['reference'] ?? '').toString(), + ); + debugPrint( + '[PAYMENT] booking $bookingId: $mode ₹$amount ' + '→ ${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}; + } + + /// The rider cannot carry this one — it needs a van. + /// + /// Wired but unreachable from the UI: there is no control for it anywhere in + /// the app. Left here because the endpoint exists and the flow it belongs to + /// is this one; adding the control is a design question about where a rider + /// says "this doesn't fit on a bike". + Future?> requireVehicle( + Object bookingId, { + required String type, + required String reason, + }) async { + final res = await MilerApi.vehicleRequired( + bookingId, + type: type, + reason: reason, + ); + return res.ok + ? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true}) + : {'status': false, 'code': res.status, 'message': res.message}; + } +} diff --git a/lib/providers/summary/riderweeklykms.dart b/lib/providers/summary/riderweeklykms.dart new file mode 100644 index 0000000..bbfe280 --- /dev/null +++ b/lib/providers/summary/riderweeklykms.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:miler/Models/summary/riderweeklykms.dart'; +import 'package:miler/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/providers/summary/summary.dart b/lib/providers/summary/summary.dart new file mode 100644 index 0000000..b6d3120 --- /dev/null +++ b/lib/providers/summary/summary.dart @@ -0,0 +1,50 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:miler/Models/summary/pickupstats.dart'; +import 'package:miler/data/api_config.dart'; +import 'package:miler/views/helpers/constants/apiconstants.dart'; +import 'package:flutter/foundation.dart'; + +class SummaryProvider { + final String baseUrl = ApiConstants.summaryApiLive; + + Future fetchSummaryStats(int userId) => + _fetchSummaryStatsNew(); + + /// NEW API: GET /miler/earnings?period=daily|weekly|monthly + /// -> data:{ completed_stops, total_kms, total_earnings, total_bonus }. + /// PickupStats is stop COUNTS per period; map completed_stops for each. + Future _fetchSummaryStatsNew() async { + Future stops(String period) async { + try { + final uri = Uri.parse( + ApiConfig.url('/miler/earnings'), + ).replace(queryParameters: {'period': period}); + final res = await http.get(uri, headers: await ApiConfig.authHeaders()); + if (res.statusCode >= 200 && res.statusCode < 300) { + final decoded = json.decode(res.body); + final d = (decoded is Map && decoded['data'] is Map) + ? decoded['data'] as Map + : {}; + return int.tryParse('${d['completed_stops'] ?? 0}') ?? 0; + } + } catch (_) {} + return 0; + } + + final today = await stops('daily'); + final week = await stops('weekly'); + final month = await stops('monthly'); + ApiConfig.logGap( + 'summaryStats', + 'v1 earnings has no cancelled/total count; derived from completed_stops.', + ); + return PickupStats( + today: today, + week: week, + month: month, + total: month, + cancelled: 0, + ); + } +} diff --git a/lib/test/tmp_m/m.dart b/lib/test/tmp_m/m.dart new file mode 100644 index 0000000..90ed150 --- /dev/null +++ b/lib/test/tmp_m/m.dart @@ -0,0 +1,84 @@ +import 'dart:ui' as ui; +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/Font_constant.dart'; + +/// Mirrors `_calloutBitmap` in map.dart so the drawing maths can be checked +/// headless — the real one is private to a State inside a `part` file. +Future<({int w, int h})> callout(String text, double dpr) async { + const double padH = 12, padV = 9, radius = 12, tail = 9, tailW = 14; + const double maxTextWidth = 190; + final painter = TextPainter( + text: TextSpan( + text: text, + style: TextStyle( + fontSize: 12 * dpr, + height: 1.25, + fontWeight: FontWeight.w700, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + maxLines: 2, + ellipsis: '…', + textDirection: TextDirection.ltr, + )..layout(maxWidth: maxTextWidth * dpr); + + final w = painter.width + padH * 2 * dpr; + final h = painter.height + padV * 2 * dpr; + final totalH = h + tail * dpr; + + final rec = ui.PictureRecorder(); + final canvas = Canvas(rec); + final body = RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, w, h), + Radius.circular(radius * dpr), + ); + canvas.drawRRect( + body.shift(Offset(0, 1.5 * dpr)), + Paint() + ..color = Colors.black.withValues(alpha: 0.18) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 3 * dpr), + ); + canvas.drawRRect(body, Paint()..color = Colors.white); + final tip = Path() + ..moveTo(w / 2 - tailW / 2 * dpr, h - 1) + ..lineTo(w / 2, totalH) + ..lineTo(w / 2 + tailW / 2 * dpr, h - 1) + ..close(); + canvas.drawPath(tip, Paint()..color = Colors.white); + canvas.drawRRect( + RRect.fromRectAndCorners( + Rect.fromLTWH(0, 0, 4 * dpr, h), + topLeft: Radius.circular(radius * dpr), + bottomLeft: Radius.circular(radius * dpr), + ), + Paint()..color = ColorConstants.primary, + ); + painter.paint(canvas, Offset(padH * dpr + 2 * dpr, padV * dpr)); + + final img = await rec.endRecording().toImage(w.ceil(), totalH.ceil()); + final bytes = await img.toByteData(format: ui.ImageByteFormat.png); + if (bytes == null) throw StateError('no png'); + return (w: img.width, h: img.height); +} + +void main() { + testWidgets('callout rasterises at every dpr', (tester) async { + for (final dpr in [1.0, 2.0, 3.0]) { + final r = await callout('18, Chinnavedampatti Road, Coimbatore', dpr); + print('dpr=$dpr ${r.w}x${r.h}px'); + expect(r.w, greaterThan(0)); + expect(r.h, greaterThan(0)); + } + // A very long address must clamp, not grow without bound. + final long = await callout( + 'Plot 42B, Second Cross Street, Behind Sri Ramakrishna Mills, ' + 'Ganapathy Post, Coimbatore, Tamil Nadu 641006', + 3.0, + ); + print('long address at dpr=3: ${long.w}x${long.h}px'); + expect(long.w, lessThanOrEqualTo((190 + 24) * 3 + 2)); + }); +} diff --git a/lib/utils/device.dart b/lib/utils/device.dart new file mode 100644 index 0000000..43e8643 --- /dev/null +++ b/lib/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) debugPrint('[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/utils/kalman_filter.dart b/lib/utils/kalman_filter.dart new file mode 100644 index 0000000..6b01c40 --- /dev/null +++ b/lib/utils/kalman_filter.dart @@ -0,0 +1,250 @@ +/// A simple 4D Kalman Filter implementation for GPS smoothing. +/// State vector x = [lat, lng, velocity_lat, velocity_lng] +class MilerKalmanFilter { + 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 + + MilerKalmanFilter({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/utils/mqtt_service.dart b/lib/utils/mqtt_service.dart new file mode 100644 index 0000000..1b38e66 --- /dev/null +++ b/lib/utils/mqtt_service.dart @@ -0,0 +1,206 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:mqtt_client/mqtt_client.dart'; +import 'package:mqtt_client/mqtt_server_client.dart'; +import 'package:miler/views/helpers/constants/mqtt_constants.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MilerMqttService { + static final MilerMqttService _instance = MilerMqttService._internal(); + + factory MilerMqttService() { + return _instance; + } + + MilerMqttService._internal(); + + MqttServerClient? _client; + bool _isConnected = false; + String? _currentRiderId; + + bool get isConnected => _isConnected; + + Future connect() async { + if (_isConnected) return; + + 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; + // Append a small random or "bg" suffix if we want to allow simultaneous connections + // from UI and Background Isolates. + final clientId = + 'rider_${_currentRiderId}_${DateTime.now().millisecondsSinceEpoch % 1000}'; + + _client = MqttServerClient(host, clientId); + _client!.port = MqttConstants.brokerPort; + _client!.keepAlivePeriod = 30; // 30 seconds + _client!.autoReconnect = true; + _client!.logging(on: false); + + // --- LAST WILL AND TESTAMENT (LWT) --- + // If the app crashes or loses internet unexpectedly, the broker will publish this. + 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; + + try { + debugPrint('[MQTT] Connecting to $host...'); + await _client!.connect(); + } catch (e) { + debugPrint('[MQTT] Connection failed: $e'); + _disconnect(); + } + } + + 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 _disconnect() { + _client?.disconnect(); + _onDisconnected(); + } + + 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 --- + + /// Updates the rider's status (Online, Offline, Idle) + void updateStatus(String status) { + _publishStatus(status); + } + + /// Updates the rider's profile (name, context, tokens etc.) + 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'); + } + + /// Publishes location data + 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!); + // Avoid excessive logging for high-frequency location updates + } + + /// Publishes telemetry data (Battery, GPS Signal, Rider Logs etc.) + 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.'); + } + + /// Publishes arbitrary logs or events + 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'); + } + + /// Generic publish to doormile/riders/{id}/{subTopic} + 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/utils/no_internet.json b/lib/utils/no_internet.json new file mode 100644 index 0000000..fcb7dae --- /dev/null +++ b/lib/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/views/Dashboard/activity/activity_page.dart b/lib/views/Dashboard/activity/activity_page.dart new file mode 100644 index 0000000..9608980 --- /dev/null +++ b/lib/views/Dashboard/activity/activity_page.dart @@ -0,0 +1,1539 @@ +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/data/accepted_store.dart'; +import 'package:miler/data/stop_compliance.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/widgets/app_widgets.dart'; +import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; +import 'package:miler/widget/Bottom_page.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ACTIVITY — the stops the rider has finished +/// +/// ── Why this tab exists ── +/// +/// Home used to keep a stop after it was completed, greyed out with a +/// "Completed" chip. It was the wrong place for it twice over: Home is the +/// screen where the rider decides what to take, so a finished stop is the one +/// kind of row he can do nothing about — and there is no upper bound on them. +/// Six stops into a shift he was scrolling past six dead cards to reach the one +/// live one, so the screen got harder to use in exact proportion to how much +/// work he had done. +/// +/// Finished work is still worth showing — it is how he checks what he did, and +/// how he answers "did that drop actually go through?" — it just is not +/// *decision* material. So it moves here, to the tab that used to be Earnings. +/// Earnings did not need a permanent tab: it is a once-or-twice-a-day read, and +/// it now opens from Account. What he did today is the thing he glances at +/// between stops. +/// +/// ── What counts as finished ── +/// +/// [StopStatusX.isFinishedPickup] — picked up or cancelled — which is the same +/// test Bookings uses to drop a stop off its working list. So every stop that +/// leaves Bookings arrives here, and the two can never disagree about what +/// "done" means. +/// +/// ── And what is parked ── +/// +/// Skipped stops are here too, in their own group above the finished ones, with +/// the reason the rider gave and a Resume control. They used to sit on Home, +/// among the stops still waiting to be accepted, and that was the wrong screen +/// for the same reason finished stops were: Home answers "what am I taking?", +/// and a skip is not on offer — it is something that already happened at a +/// door. It also accumulated. By mid-shift the accept list carried a permanent +/// tail of stops the rider had already put down, and he had to read past his +/// own morning to find the afternoon's work. +/// +/// They lead this tab rather than trailing it because they are the one thing on +/// 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. +/// ───────────────────────────────────────────────────────────────────────── +class ActivityPage extends StatefulWidget { + const ActivityPage({super.key}); + + @override + State createState() => _ActivityPageState(); +} + +class _ActivityPageState extends State { + final PickupProvider _provider = PickupProvider(); + + List> _done = >[]; + + /// Stops parked for a return visit, newest first. Kept apart from [_done] + /// because they are the opposite kind of row: unfinished, and the only ones + /// on this page that still carry an action. + List> _skipped = >[]; + + /// 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 = {}; + + bool _firstLoadDone = false; + bool _failed = false; + bool _fetching = false; + + /// 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(); + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + // ── Re-read whenever the tab comes forward ── + // + // The shell keeps all four tabs alive in an `IndexedStack`, so this + // `initState` runs once at app launch — before the rider has completed + // anything. Without this listener the tab showed that first, empty result + // for the rest of the session: he finished a stop, came here, and found + // nothing, which is exactly the bug this page was added to prevent. + BottomPage.currentTab.addListener(_onTabChanged); + } + + @override + void dispose() { + BottomPage.currentTab.removeListener(_onTabChanged); + super.dispose(); + } + + void _onTabChanged() { + if (BottomPage.currentTab.value == _activityTabIndex && mounted) _load(); + } + + /// This page's slot in the shell. See [BottomPage.goToTab]. + static const int _activityTabIndex = 2; + + /// Today's finished stops, from the same two endpoints Bookings reads. + /// + /// Both are asked for, not just the "picked" one: v3 carries the completed + /// records and v1 carries the live statuses, and when they disagree about a + /// stop the live one is right — the same precedence Bookings applies, for the + /// same reason. + Future _load() async { + if (_fetching) return; + _fetching = true; + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0; + + final results = await Future.wait([ + _safe(() => _provider.getPickupQueuesPicked()), + _safe(() => _provider.getCurrentPickups()), + ]); + + final Map> merged = {}; + for (final row in results[0].whereType>()) { + merged[_keyOf(row)] = row; + } + for (final row in results[1].whereType>()) { + final key = _keyOf(row); + merged[key] = {...?merged[key], ...row}; + } + + // ── The local record wins ── + // + // Finishing a stop is optimistic: the rider is moved on whether or not + // the status call landed, and the queue endpoints will not report the + // stop as finished for a poll or two — on demo data, never. Reading only + // the API meant a rider could complete a booking and find this tab empty, + // which is precisely what it exists to disprove. + // + // 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. + for (final row in await getCompletedBookings()) { + final key = _keyOf(row); + merged[key] = {...?merged[key], ...row}; + } + + // 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 + // resumed since is not in it, so it cannot re-park something the rider + // has taken back. + final skippedStore = await getSkippedBookings(); + for (final row in skippedStore) { + final key = _keyOf(row); + merged[key] = {...?merged[key], ...row}; + } + final skippedIds = skippedStore + .map((r) => (r['orderid'] ?? '').toString()) + .toSet(); + // Resuming writes the stop into the accepted store, so that is the record + // that says "taken back" while the queue endpoint still reports 'skipped'. + final acceptedIds = await getAcceptedOrderIds(); + + final rows = merged.values.toList(); + // Newest first: the stop he just finished is the one he is most likely + // checking, and it should not be at the bottom of the day. + final done = rows.where((o) => stopStatusOf(o).isFinishedPickup).toList() + ..sort((a, b) => _happenedAt(b).compareTo(_happenedAt(a))); + final skipped = rows.where((o) { + if (!stopStatusOf(o).isSkipped) return false; + final id = (o['orderid'] ?? '').toString(); + // In the store → parked, full stop. Otherwise trust the backend + // unless the rider has since taken it back: a stop the queue still + // calls 'skipped' but which sits in the accepted store has been + // resumed, and offering Resume on it again would be nonsense. + return skippedIds.contains(id) || !acceptedIds.contains(id); + }).toList()..sort((a, b) => _happenedAt(b).compareTo(_happenedAt(a))); + + if (!mounted) return; + setState(() { + _done = done; + _skipped = skipped; + _failed = false; + _firstLoadDone = true; + }); + } catch (e) { + debugPrint('[ACTIVITY] load failed: $e'); + if (!mounted) return; + setState(() { + _failed = true; + _firstLoadDone = true; + }); + } finally { + _fetching = false; + } + } + + Future> _safe(Future> Function() call) async { + try { + return await call(); + } catch (e) { + debugPrint('[ACTIVITY] endpoint failed: $e'); + return []; + } + } + + String _keyOf(Map o) { + final id = (o['orderid'] ?? '').toString(); + 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); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ColorConstants.pureSurface, + 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), + ), + 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 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)); + + 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), + ], + ], + ], + ); + } + + /// 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, + }) { + 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), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(12.w, 12.h, 12.w, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 34.w, + height: 34.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon(kind.icon, size: 17.sp, color: accent), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + 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, + ), + ), + ], + ], + ), + 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, + ), + ), + ], + 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, + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + 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, + 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), + ), + ], + ), + ), + ], + ), + ); + } + + /// 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. + Future _resume(Map stop) async { + final id = (stop['orderid'] ?? '').toString(); + if (id.isEmpty || _busyIds.contains(id)) return; + + setState(() => _busyIds.add(id)); + try { + final ok = await MyPickups.resumeSkippedStop(stop); + if (!mounted) return; + if (!ok) { + AppFeedback.error(context, 'Could not resume this stop'); + return; + } + setState(() { + _skipped = _skipped + .where((s) => (s['orderid'] ?? '').toString() != id) + .toList(); + }); + AppFeedback.success(context, 'Back on your Bookings 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/home/accepted_pill.dart b/lib/views/Dashboard/home/accepted_pill.dart new file mode 100644 index 0000000..08fd9e5 --- /dev/null +++ b/lib/views/Dashboard/home/accepted_pill.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.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/widget/Bottom_page.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ACCEPTED PILL — how many stops you have taken, and the way to them +/// +/// Home is where the rider *decides*; Bookings is where he *works*. The bridge +/// between the two used to be a "Continue trip" button sitting under the last +/// hub row of a trip — which meant it only existed once that particular trip +/// had nothing left to decide, it moved as the route grew, and it was below the +/// fold on any route longer than about four stops. A rider who had accepted +/// 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 +/// 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 +/// the foot of the page and never appear together. A selection in progress is +/// the more urgent of the two and wins the slot. +/// +/// The count comes from the accepted store, which drops a booking once it is +/// picked, so the number is "waiting for you on Bookings" rather than "accepted +/// at some point today". +/// ───────────────────────────────────────────────────────────────────────── +class AcceptedPill extends StatelessWidget { + /// Accepted bookings still waiting to be worked. Zero renders nothing. + final int count; + + /// Jumps to the Bookings tab. + final VoidCallback onTap; + + const AcceptedPill({super.key, required this.count, required this.onTap}); + + @override + Widget build(BuildContext context) { + if (count <= 0) return const SizedBox.shrink(); + + final radius = BorderRadius.circular(DesignConstants.radiusFull); + + // Rides the floating nav bar: it is `Positioned` outside the Scaffold's + // body, so `SafeArea` knows nothing about it and anything pinned to the + // bottom lands underneath. See [BottomPage.bottomInset]. + // + // A fixed inset now. This used to follow a `navVisible` notifier up and + // down, because the bar left the screen entirely on scroll. It compacts + // instead of leaving, so there is no longer a state in which the room is + // free — and chasing the 20pt difference re-laid this out every time the + // rider changed scroll direction, for a gap he would never notice. + return Padding( + padding: EdgeInsets.only(bottom: BottomPage.bottomInset(context) + 10.h), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Align( + // Right-aligned and sized to its content: this is an ambient way + // through, not the action the screen is for. A full-width green bar + // here would out-rank the accept the rider is actually on Home to + // make. + alignment: Alignment.centerRight, + child: Semantics( + button: true, + label: count == 1 + ? '1 accepted booking. Go to Bookings.' + : '$count accepted bookings. Go to Bookings.', + child: Material( + color: ColorConstants.primary, + borderRadius: radius, + elevation: 0, + child: InkWell( + onTap: onTap, + borderRadius: radius, + child: Container( + // 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), + decoration: BoxDecoration( + borderRadius: radius, + boxShadow: DesignConstants.shadowLg, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // The count as a badge, not as a word in a sentence: it + // 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, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + shape: BoxShape.circle, + ), + child: Text( + '$count', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w800, + height: 1, + letterSpacing: -0.3, + color: ColorConstants.primary, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 9.w), + Text( + count == 1 ? 'booking accepted' : 'bookings accepted', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.1, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(width: 2.w), + Icon( + Icons.chevron_right_rounded, + size: 20.sp, + color: Colors.white, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/home/duty_sheet.dart b/lib/views/Dashboard/home/duty_sheet.dart new file mode 100644 index 0000000..d932c86 --- /dev/null +++ b/lib/views/Dashboard/home/duty_sheet.dart @@ -0,0 +1,481 @@ +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'; + +/// ───────────────────────────────────────────────────────────────────────── +/// 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". +/// +/// Three things were wrong with that, and none of them was the styling: +/// +/// • **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]. +/// +/// 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. +/// +/// 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. +/// ───────────────────────────────────────────────────────────────────────── +class DutySheet extends StatelessWidget { + /// True when this sheet is asking to go **on** duty. + final bool goingOnline; + + /// Runs on a completed slide, and owns the work: the pending-booking check, + /// the API call, the polling restart. Returns true when duty actually + /// changed. + /// + /// The knob belongs to the sheet, not the caller — it springs back on every + /// outcome, and the sheet closes itself only on a true. That is why this is a + /// `Future` and not the package's controller: the controller type is + /// not exported from `slide_to_submit_button`, so naming it here would mean + /// reaching into the package's `src/`. + final Future Function() onConfirm; + + const DutySheet({ + super.key, + required this.goingOnline, + required this.onConfirm, + }); + + /// Opens the sheet. The caller keeps the toggle logic; this only presents it. + static Future show( + BuildContext context, { + 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), + ); + } + + /// Green to start, brand red to stop. + /// + /// Green matches the duty switch's own "on" fill, so the sheet is visibly + /// about the control that opened it. Stopping is not grey: grey is what a + /// disabled control looks like, and this is the most consequential button on + /// the screen. + Color get _accent => + goingOnline ? ColorConstants.success : ColorConstants.primary; + + @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, + 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, + ), + ), + ), + ], + ), + ); + } +} + +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, + style: TextStyle( + fontSize: 13.sp, + height: 1.35, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.slateText, + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/Dashboard/home/homepage.dart b/lib/views/Dashboard/home/homepage.dart new file mode 100644 index 0000000..b64d838 --- /dev/null +++ b/lib/views/Dashboard/home/homepage.dart @@ -0,0 +1,4090 @@ +// ignore_for_file: unused_element, unused_import + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lottie/lottie.dart' hide Marker; +import 'dart:convert'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +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/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_app_bar.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +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/controllers/pickup.dart'; +import 'package:miler/controllers/pickups_controller.dart'; +import 'package:miler/controllers/summary_controller.dart'; +import 'package:geolocator/geolocator.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:geocoding/geocoding.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/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'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; +import 'package:miler/views/Dashboard/home/stop_detail_sheet.dart'; +import 'package:miler/views/Dashboard/home/trip_tabs.dart'; +import 'package:miler/views/Dashboard/home/trip_empty_state.dart'; +import 'package:miler/views/Dashboard/home/duty_sheet.dart'; +import 'package:miler/views/Dashboard/profile/Profilepage.dart'; +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/views/Dashboard/pickups/collect_payment.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; +import 'package:miler/widget/Bottom_page.dart'; + +/// Helper function to launch phone dialer +Future launchPhoneDialer(String phoneNumber) async { + try { + final phone = phoneNumber.replaceAll(RegExp(r'[^\d+]'), ''); + if (phone.isEmpty) { + debugPrint('[PHONE] Empty phone number after sanitization'); + return false; + } + final Uri uri = Uri(scheme: 'tel', path: phone); + try { + final launched = await launchUrl(uri, mode: LaunchMode.platformDefault); + if (launched) { + debugPrint('[PHONE] Successfully launched dialer for: $phone'); + return true; + } + } 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, WidgetsBindingObserver { + // ==================== MILER BRAND PALETTE ==================== + // Aligned to the shared design tokens (ColorConstants) so Home matches + // the rest of the app. + static final Color _primaryRed = ColorConstants.primary; // #960019 + static Color get _darkRed => ColorConstants.primary; + static Color get _lightRed => ColorConstants.errorLight; + static final Color _redAccent = ColorConstants.primaryContainer; // #C01227 + static Color get _successColor => ColorConstants.success; + static Color get _warningColor => ColorConstants.warning; + // Slate-tinted daylight grey (#F8FAFC), not the old warm #FCF9F8. The warm + // surface sat ~3% from white, so white cards on it had no visible edge in + // sunlight — riders reported not being able to tell cards apart. + static Color get _surfaceColor => ColorConstants.daylightSurface; + static Color get _cardColor => ColorConstants.pureSurface; + static Color _textPrimary = + ColorConstants.onSurface; // app-standard near-black + static Color get _textSecondary => ColorConstants.onSurfaceVariant; + static final Color _textTertiary = ColorConstants.secondaryText; // #8E8E93 + + // ==================== STATE VARIABLES ==================== + bool isOnline = true; + String _userName = ''; + String _city = ''; + String _shiftStart = ''; + String _shiftEnd = ''; + int _pickupsToday = 0; + final PickupProvider _pickupProvider = PickupProvider(); + final PickupController _pickupController = Get.find(); + + Map> _ordersMap = {}; + List _orderIds = []; + + StreamSubscription? _pollerSubscription; + Duration? _currentPollInterval = const Duration(seconds: 0); + bool _isToggling = false; + + Map _selectedOrders = {}; + bool isAllSelected = false; + + String _lastQueuesJson = ''; + bool _loadedOnlineFromPrefs = false; + bool _isFetchingQueues = false; + + /// False until the first queue fetch has returned. + /// + /// Home is trip-shaped, and "no trips" renders as "Trip 1 not assigned yet" — + /// a definite statement about the rider's day. On a cold open it was made + /// before the request had come back, so the landing screen of the app told + /// every rider his hub had given him nothing, then quietly replaced it with + /// his route. Not knowing yet gets its own state now. + bool _queuesLoaded = false; + + /// False while the app is backgrounded. Polling a screen nobody is looking + /// at spends the rider's battery on information he cannot see. + bool _pollForeground = true; + + /// Set when a fetch fails and we have nothing to show. Without it an empty + /// list after a network error rendered as "Trip 1 not assigned yet" — the app + /// reporting its own failure to the rider as good news about his workload. + bool _fetchFailed = false; + + final Map _pickupRunning = {}; + final Map _pickupTimers = {}; + final Map> _pickupBasePayload = + >{}; + final CreatePickupLogProvider _pickupLogProvider = CreatePickupLogProvider(); + final Map _statusBusy = {}; + + Timer? _locationTimer; + final Set _hiddenOrderIds = {}; + List> _activePickups = >[]; + + static final http.Client _httpClient = http.Client(); + Position? _lastGeocodedPosition; + + /// Where the rider currently is. Stands in for the hub he set out from when + /// measuring the round-trip length of the remaining route. + double? _riderLat; + double? _riderLng; + + /// Stops the rider has accepted (now on the Bookings tab) — off this list, + /// but still part of the route the header summarises. + List> _acceptedStops = >[]; + + /// Id of the trip currently being accepted, so only that card shows a + /// spinner instead of every card on the screen. + String? _acceptingTripId; + + /// Order ids with a single-stop action in flight. + final Set _busyStopIds = {}; + + /// Every stop the hub assigned today, in any state — the source the trip + /// cards are built from. See the note in `_fetchQueues`. + List> _allTripStops = >[]; + + /// Locally recorded decisions. Optimistic: the rider's tap lands here + /// immediately so a slow network can never bounce a stop back to pending + /// under his thumb. + Set _acceptedIds = {}; + Set _rejectedIds = {}; + + /// Which trip tab is open. + int _selectedTripIndex = 0; + + /// Stops the rider has ticked, for "accept just these". + Set _selectedStopIds = {}; + + // ==================== LIFECYCLE METHODS ==================== + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + final keys = List.from(_pickupTimers.keys); + for (final k in keys) { + _stopPickupPosting(k); + } + _pollerSubscription?.cancel(); + _locationTimer?.cancel(); + _httpClient.close(); + super.dispose(); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _loadName(); + _loadPickupsToday(); + _ensureLocationPermission(); + _ensureCameraPermission(); + _startPolling(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _fetchQueues(); + }); + try { + if (Get.isRegistered()) { + final pc = Get.find(); + ever(pc.userName, (_) { + _loadName(); + }); + } + } catch (_) {} + } + + @override + bool get wantKeepAlive => true; + + // ==================== HELPER METHODS ==================== + void _stopPickupPosting(String orderId) { + _pickupTimers[orderId]?.cancel(); + _pickupTimers.remove(orderId); + } + + String _getStatusFromOrder(Map Booking) { + final apiStatus = (Booking['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + if (apiStatus == 'accepted') return 'ACCEPTED'; + if (apiStatus == 'arrived') return 'ARRIVED'; + if (apiStatus == 'picked') return 'PICKED'; + return 'ACCEPT'; + } + + double _parseDouble(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + String _getTimeGreeting() { + final hour = DateTime.now().hour; + if (hour < 12) return "Morning"; + if (hour < 17) return "Afternoon"; + return "Evening"; + } + + bool _hasActivePickup() { + return _activePickups.isNotEmpty; + } + + // ==================== LOCATION METHODS ==================== + Future<(String lat, String lng)?> _getValidCoordinates() async { + try { + await _ensureLocationPermission(); + Position? pos; + try { + 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 _ensureLocationPermission() async { + try { + final prefs = await SharedPreferences.getInstance(); + bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + final lastPos = await Geolocator.getLastKnownPosition(); + if (lastPos != null) { + await prefs.setString('last_lat', lastPos.latitude.toString()); + await prefs.setString('last_lng', lastPos.longitude.toString()); + } + 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) { + await prefs.setString('last_lat', lastPos.latitude.toString()); + await prefs.setString('last_lng', lastPos.longitude.toString()); + } + return; + } + + Position? pos; + try { + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.medium, + timeLimit: const Duration(seconds: 5), + ).timeout(const Duration(seconds: 5)); + } catch (e) { + debugPrint('[HOMEPAGE] Timeout getting location: $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 { + 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) { + await Permission.camera.request(); + } + } catch (e) { + debugPrint('[HOMEPAGE] Error ensuring camera permission: $e'); + } + } + + void _startLocationUpdates() { + _locationTimer?.cancel(); + _locationTimer = Timer.periodic(const Duration(seconds: 30), (_) { + _getAndUpdateCurrentLocation(); + }); + } + + 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) { + return; + } + } + + if (permission == LocationPermission.deniedForever) { + return; + } + + Position position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, + ); + + if (_lastGeocodedPosition != null) { + final dist = Geolocator.distanceBetween( + _lastGeocodedPosition!.latitude, + _lastGeocodedPosition!.longitude, + position.latitude, + position.longitude, + ); + if (dist < 100) return; + } + + try { + List placemarks = await placemarkFromCoordinates( + position.latitude, + position.longitude, + ); + if (placemarks.isNotEmpty) { + _lastGeocodedPosition = position; + 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 ?? ''; + if (mounted && city.isNotEmpty && city != _city) { + setState(() => _city = city); + } + 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'); + } + + 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, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Spacer(), + IconButton( + tooltip: 'Close', + icon: Icon(Icons.close, color: _textSecondary), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + 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), + MilerButton( + label: "Enable location", + color: _primaryRed, + onPressed: () async { + Navigator.of(ctx).pop(); + await Geolocator.openLocationSettings(); + }, + ), + ], + ), + ), + ); + } + + // ==================== PROXIMITY METHODS ==================== + Future _isNearPickupLocation(Map Booking) async { + try { + final pickupLat = _parseDouble( + Booking['pickuplat'] ?? Booking['PickupLat'], + ); + final pickupLng = _parseDouble( + Booking['pickuplon'] ?? Booking['PickupLon'], + ); + if (pickupLat == 0 || pickupLng == 0) return true; + final riderLoc = await _getValidCoordinates(); + if (riderLoc == null) return true; + 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, + ); + return distanceInMeters <= 500; + } catch (e) { + debugPrint('[PROXIMITY] Error checking proximity: $e'); + return true; + } + } + + Future> _checkProximityForOrders( + List orderIds, + ) async { + final Map results = {}; + for (final orderId in orderIds) { + final Booking = _ordersMap[orderId]; + results[orderId] = Booking != null + ? await _isNearPickupLocation(Booking) + : false; + } + return results; + } + + Future _showProximityWarning( + BuildContext context, { + String? specificMessage, + }) async { + return showAppDialog( + context: context, + barrierDismissible: true, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(DesignConstants.radiusXl)), + title: Row( + children: [ + Icon(Icons.location_off, color: _primaryRed, 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, + color: _textPrimary, + ), + ), + ), + ], + ), + content: Text( + specificMessage ?? + 'You must be within 500 meters of the pickup location to mark this Booking as arrived.', + style: TextStyle( + fontSize: FontConstants.regular(context), + fontFamily: FontConstants.fontFamily, + color: _textSecondary, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + style: TextButton.styleFrom(foregroundColor: _primaryRed), + child: Text( + 'OK', + style: TextStyle( + fontSize: FontConstants.regular(context), + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } + + // ==================== ONLINE/OFFLINE TOGGLE ==================== + // + // The sheet itself is [DutySheet] — chrome, copy and the slide control. What + // stays here is the work: the pending-booking gate, the API call and the + // polling and tracking that follow it. + void _confirmOnlineOffline() { + if (_isToggling) return; + final bool newStatus = !isOnline; + if (!mounted) return; + + DutySheet.show( + context, + goingOnline: newStatus, + onConfirm: () => _applyDutyChange(newStatus), + ); + } + + /// Carries out the duty change the sheet just asked for. + /// + /// Returns true when duty actually changed — the sheet uses that to decide + /// whether to close or to spring its knob back and stay put. + Future _applyDutyChange(bool newStatus) async { + if (!mounted || _isToggling) return false; + + // ── The one thing that can refuse ── + // + // A rider cannot walk away from work he is holding. This is checked here + // rather than by disabling the switch, because the answer depends on a + // stored flag that is cheaper to read on demand than to watch. + if (newStatus == false) { + bool hasPending = + _ordersMap.isNotEmpty || _pickupRunning.values.any((v) => v); + try { + final prefs = await SharedPreferences.getInstance(); + hasPending = hasPending || (prefs.getBool('has_live_pickup') ?? false); + } catch (_) {} + if (hasPending) { + if (!mounted) return false; + try { + await showAppDialog( + context: context, + builder: (dCtx) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + title: Text( + 'Finish your bookings first', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + color: _textPrimary, + fontWeight: FontWeight.bold, + ), + ), + content: Text( + 'You are still holding work. Complete or hand back your open ' + 'bookings, then you can go off duty.', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + color: _textSecondary, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dCtx).pop(), + child: Text( + 'OK', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + color: _primaryRed, + ), + ), + ), + ], + ), + ); + } catch (_) {} + return false; + } + } + + if (!mounted) return false; + _isToggling = true; + showAppDialog( + context: context, + barrierDismissible: false, + builder: (dialogCtx) => Center( + child: Container( + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: SizedBox( + height: 40, + width: 40, + child: CircularProgressIndicator( + strokeWidth: 3.2, + valueColor: AlwaysStoppedAnimation(_primaryRed), + ), + ), + ), + ), + ); + + 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 false; + setState(() { + isOnline = ok ? newStatus : isOnline; + }); + + // Broadcast through the single source of truth so the pickups gate + every + // screen agree instantly. + try { + await DutyController.to.setOnlineLocal(isOnline); + } catch (_) {} + + // ── Say what happened, not "Offline" ── + // + // This line was `successGlobal('Offline')`, unconditionally: it congratulated + // the rider on going offline when he had just come *on* duty, and said the + // same thing when the call had failed and his duty had not changed at all. + if (mounted) { + if (ok) { + AppFeedback.successGlobal( + newStatus ? "You're on duty" : "You're off duty", + ); + } else { + AppFeedback.errorGlobal("Couldn't change your duty — try again"); + } + } + + if (ok && newStatus) { + _startLocationUpdates(); + LiveTrackingService().startTracking(); + _fetchQueues(); + _startPolling(); + } else if (ok && !newStatus) { + _locationTimer?.cancel(); + LiveTrackingService().stopTracking(); + _fetchQueues(); + _startPolling(); + } + + return ok; + } + + 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) { + debugPrint('[BREAK] Ending break...'); + 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...'); + 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; + } + } + + // ==================== DATA FETCHING ==================== + 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(); + + // Last fix written by _getValidCoordinates / the location loop — the + // origin for the route-length estimate in the header. + _riderLat = + _lastGeocodedPosition?.latitude ?? + double.tryParse(prefs.getString('last_lat') ?? ''); + _riderLng = + _lastGeocodedPosition?.longitude ?? + double.tryParse(prefs.getString('last_lng') ?? ''); + + // Duty state now resolves through the single DutyController (correct + // priority lives there), so the gate agrees across every screen. + final bool online = await DutyController.to.load(); + + if (!mounted) return; + setState(() { + _userName = name; + if (!_loadedOnlineFromPrefs) { + isOnline = online; + if (isOnline) { + try { + if (Get.isRegistered()) { + Get.find().startLogging(); + } + } catch (_) {} + } + _loadedOnlineFromPrefs = true; + } + }); + } catch (_) { + if (!mounted) return; + setState(() { + _userName = name; + isOnline = true; + }); + } + } + + /// Loads today's completed-pickup count from the shared SummaryController + /// so the hero shows a real metric instead of a hardcoded placeholder. + Future _loadPickupsToday() async { + try { + final controller = Get.isRegistered() + ? Get.find() + : Get.put(SummaryController()); + final prefs = await SharedPreferences.getInstance(); + final uid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + if (uid > 0) { + await controller.fetchSummaryStats(uid); + } + if (!mounted) return; + setState(() => _pickupsToday = controller.today.value); + } catch (_) {} + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + final bool foreground = state == AppLifecycleState.resumed; + if (foreground == _pollForeground) return; + _pollForeground = foreground; + if (foreground) { + // Catch up once on the way back in, then resume the normal cadence. + _fetchQueues(); + _startPolling(); + } else { + _startPolling(); + } + } + + void _startPolling() { + if (!_pollForeground) { + _pollerSubscription?.cancel(); + _pollerSubscription = null; + _currentPollInterval = null; + return; + } + + // 5s while a stop is live, 30s waiting for the hub, 60s off duty — the + // shared policy, so Home and Bookings cannot disagree about how hard they + // work the rider's battery. See [pollInterval]. + final interval = pollInterval( + hasLiveWork: _activePickups.isNotEmpty, + onDuty: isOnline, + ); + + if (_pollerSubscription != null && _currentPollInterval == interval) { + return; + } + + _pollerSubscription?.cancel(); + _currentPollInterval = interval; + + _pollerSubscription = Stream.periodic(interval, (_) {}) + .asyncMap((_) async { + if (mounted && !_isFetchingQueues) { + await _fetchQueues(); + } + }) + .listen( + (_) {}, + onError: (error) { + debugPrint('[HOMEPAGE][STREAM ERROR] $error'); + }, + cancelOnError: false, + ); + } + + /// Calls a stop's customer, and says so when the dialer will not open. + Future _callStop(Map stop) async { + final phone = (stop['pickupcontactno'] ?? '').toString(); + final ok = await launchPhoneDialer(phone); + if (!ok && mounted) { + AppFeedback.error( + context, + phone.trim().isEmpty + ? 'No phone number on this stop' + : "Couldn't open the dialer — $phone", + ); + } + } + + /// Pull-to-refresh on Home. + /// + /// Deliberately not `onRefresh: _fetchQueues`, for two reasons. + /// + /// **The background poll gets in the way.** [_fetchQueues] self-guards with + /// [_isFetchingQueues] and returns *immediately* when a fetch is already + /// running — and with a 5s poll that is close to a coin flip on any given + /// pull. Handing that future straight to the indicator would dismiss the + /// spinner the moment the rider let go, about half the time, which looks + /// exactly like a refresh that failed. So a pull waits for the in-flight + /// fetch to land rather than racing it. + /// + /// **An instant answer still has to look like an answer.** [_fetchQueues] + /// short-circuits when the payload is unchanged (`_lastQueuesJson`), so a + /// refresh can finish in a few milliseconds and the spinner would blink out + /// before it was ever read as spinning. The floor below is what every app the + /// rider already uses does here. + Future _handleRefresh() async { + // 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), + ); + + try { + // Wait out a poll already in flight. Bounded, so a hung request cannot + // pin the spinner to the screen indefinitely. + final deadline = DateTime.now().add(const Duration(seconds: 6)); + while (_isFetchingQueues && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 80)); + } + + // Name, city and the shift window come from the profile; the trips come + // from the queue. He pulled to re-read the screen, not half of it. + if (mounted) await _loadName(); + if (mounted) await _fetchQueues(); + } catch (e) { + // Never rethrow into the indicator: an error out of onRefresh becomes an + // unhandled framework exception, and the rider's recourse is to pull + // again either way. Both calls already log their own failures. + debugPrint('[REFRESH] Home refresh failed: $e'); + } + + await minimumSpin; + } + + Future _fetchQueues() async { + if (_isFetchingQueues) return; + + try { + _isFetchingQueues = true; + + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid'); + + if (userId == null) { + // Not signed in — there is no route to show. The debug branch that used + // to fill this with seeded demo trips is gone with the rest of the mock + // layer: Home is now a window onto the hub's answer, and an empty one + // means the hub said nothing. + if (!mounted) return; + setState(() { + _ordersMap.clear(); + _orderIds.clear(); + _selectedOrders.clear(); + isAllSelected = false; + }); + return; + } + + // Keep the online/offline UI in sync for the empty-state nudge, but do NOT + // hard-block here. The backend owns assignment; hiding API-returned + // bookings behind a flaky local duty flag silently loses real work. The + // "on-duty to start a pickup" rule is enforced (loudly) at the action via + // ensureOnlineForAction — not by dropping data on the floor. + final bool online = await DutyController.to.load(); + if (mounted && isOnline != online) { + setState(() => isOnline = online); + } + + // Try fetching from API. On the live/new backend we show ONLY real data + // (an empty queue stays empty); mock data is used solely on the old demo + // path so testers still see something offline. + List items; + try { + items = await _pickupProvider.getPickupQueues(); + } catch (e) { + debugPrint('[FETCH_QUEUES] API error: $e'); + items = []; + } + + if (!mounted) return; + + await _fetchActivePickups(userId); + + // Bookings the rider already accepted or rejected (persisted) must not + // reappear in the Home queue, even after a refetch or coming back to the + // Home tab. + final acceptedIds = await getAcceptedOrderIds(); + final rejectedIds = await getRejectedOrderIds(); + // Stops parked for a return visit. They are Activity's now — see the + // stamping loop below and `TripCard._showsOnHome`. + final skippedIds = await getSkippedOrderIds(); + + // 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 + // collected and their kilometres still to be ridden. The route brief + // must count them or it understates the day. + final acceptedStops = await getAcceptedBookings(); + if (mounted && acceptedStops.length != _acceptedStops.length) { + setState(() => _acceptedStops = acceptedStops); + } else { + _acceptedStops = acceptedStops; + } + + // EVERY stop the hub assigned today, whatever its state. + // + // Home used to drop a stop the moment it was accepted, which emptied the + // screen out as the rider worked and made it impossible to show trip + // progress. A trip is a unit: it stays on Home from assignment until the + // last stop is resolved, and each stop renders its own state (pending / + // accepted / active / done / rejected) via `stopStateOf`. + // + // Accepted stops still also appear on Bookings — that is where he WORKS + // them. Home is where he watches the trip fill up. + final allStops = items.whereType>().toList(); + + // ── The skip store is the truth about what is parked ── + // + // The queue endpoints are behind the rider by up to a poll, and on demo + // data they never catch up at all, so a raw `orderstatus` alone gets both + // directions of a skip wrong: a stop he has just skipped still reports as + // live, and one he has just resumed from Activity still reports as + // 'skipped' — and `stopStateOf` reads a raw 'skipped' ahead of any local + // decision, so the resume would silently undo itself within seconds. + // + // Stamped both ways here, from the same store Activity reads, so the + // progress rail and Activity can never disagree about which stops are + // parked. + for (final s in allStops) { + final id = (s['orderid'] ?? '').toString(); + if (id.isEmpty) continue; + final raw = stopStatusFromRaw(s['orderstatus']); + if (skippedIds.contains(id)) { + s['orderstatus'] = 'skipped'; + } else if (raw == StopStatus.skipped && acceptedIds.contains(id)) { + s['orderstatus'] = 'accepted'; + } + } + + // Still-undecided stops only — the accept/reject actions and the "N + // pending" counts operate on this narrower set. + final validOrders = allStops.where((m) { + final oid = (m['orderid'] ?? '').toString(); + if (oid.isNotEmpty && _hiddenOrderIds.contains(oid)) return false; + if (oid.isNotEmpty && acceptedIds.contains(oid)) return false; + if (oid.isNotEmpty && rejectedIds.contains(oid)) return false; + final st = stopStatusFromRaw(m['orderstatus']); + return st != StopStatus.active && + st != StopStatus.accepted && + st != StopStatus.arrived && + st != StopStatus.picked && + st != StopStatus.cancelled && + st != StopStatus.rejected && + // A parked stop is not on offer. It was never rendered as a + // decision row, but it was still counted as one — so "3 stops to + // accept" included stops the rider had already put down. + st != StopStatus.skipped; + }).toList(); + + // 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} ' + 'afterFilters=${validOrders.length} ' + 'hidden=${_hiddenOrderIds.length} accepted=${acceptedIds.length} ' + 'rejected=${rejectedIds.length}', + ); + + // Keyed on ALL stops plus the local decision sets: a stop moving from + // accepted to done changes no pending row, but it does move the trip's + // completion ring, so the card has to repaint. + final newJson = jsonEncode({ + 'all': allStops, + 'accepted': acceptedIds.toList()..sort(), + 'rejected': rejectedIds.toList()..sort(), + }); + if (newJson == _lastQueuesJson) { + return; + } + _lastQueuesJson = newJson; + + final Map> newOrdersMap = {}; + final List newOrderIds = []; + + for (final Booking in validOrders) { + final orderId = (Booking['orderid'] ?? '').toString(); + if (orderId.isEmpty) continue; + newOrdersMap[orderId] = Booking; + newOrderIds.add(orderId); + } + + final Map newSelection = {}; + for (final orderId in newOrderIds) { + newSelection[orderId] = _selectedOrders[orderId] ?? false; + } + + setState(() { + _allTripStops = allStops; + _acceptedIds = acceptedIds; + _rejectedIds = rejectedIds; + _ordersMap = newOrdersMap; + _orderIds = newOrderIds; + _selectedOrders = newSelection; + isAllSelected = + _selectedOrders.isNotEmpty && + _selectedOrders.values.every((selected) => selected); + for (final orderId in _orderIds) { + if (!_pickupRunning.containsKey(orderId)) { + _pickupRunning[orderId] = false; + } + } + }); + } catch (e) { + debugPrint('[FETCH_QUEUES] Error: $e'); + if (mounted) setState(() => _fetchFailed = true); + } finally { + _isFetchingQueues = false; + if (!_queuesLoaded && mounted) { + setState(() => _queuesLoaded = true); + } + } + } + + /// Applies a set of stops to the trip state. Shared by the normal fetch and + /// the signed-out demo path. + void _applyStops( + List> allStops, + Set acceptedIds, + Set rejectedIds, + ) { + if (!mounted) return; + + final Map> ordersMap = {}; + final List orderIds = []; + for (final stop in allStops) { + final id = (stop['orderid'] ?? '').toString(); + if (id.isEmpty) continue; + if (_hiddenOrderIds.contains(id)) continue; + if (acceptedIds.contains(id) || rejectedIds.contains(id)) continue; + final st = stopStatusFromRaw(stop['orderstatus']); + if (st == StopStatus.active || + st == StopStatus.accepted || + st == StopStatus.arrived || + st == StopStatus.picked || + st == StopStatus.cancelled || + st == StopStatus.rejected || + st == StopStatus.skipped) { + continue; + } + ordersMap[id] = stop; + orderIds.add(id); + } + + setState(() { + _fetchFailed = false; + _allTripStops = allStops; + _acceptedIds = acceptedIds; + _rejectedIds = rejectedIds; + _ordersMap = ordersMap; + _orderIds = orderIds; + _selectedOrders = {for (final id in orderIds) id: false}; + }); + } + + 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(); + } catch (e) { + debugPrint('[HOMEPAGE] Error fetching active bookings: $e'); + } + + final activeOrders = itemsV2.whereType>().where(( + Booking, + ) { + final status = (Booking['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + return status == 'active'; + }).toList(); + + if (mounted) { + setState(() { + _activePickups = activeOrders; + }); + } + } catch (e) { + debugPrint('[HOMEPAGE] Error fetching active pickup: $e'); + } + } + + Future _navigateToActivePickup(Map pickup) 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(); + } + } + } + + // ==================== PICKUP LOG METHODS ==================== + void _startPickupPosting(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, + 'pickupid': it['pickupid'] ?? 0, + 'userid': it['userid'] ?? 0, + 'orderid': orderId, + 'orderstatus': 'picked', + }; + _pickupBasePayload[orderId] = base; + + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'pickuplog_${orderId}_tenantid', + (base['tenantid'] ?? 0).toString(), + ); + await prefs.setString( + 'pickuplog_${orderId}_partnerid', + (base['partnerid'] ?? 0).toString(), + ); + await prefs.setString( + 'pickuplog_${orderId}_locationid', + (base['locationid'] ?? 0).toString(), + ); + await prefs.setString( + 'pickuplog_${orderId}_orderheaderid', + (base['orderheaderid'] ?? 0).toString(), + ); + await prefs.setString( + 'pickuplog_${orderId}_pickupid', + (base['pickupid'] ?? 0).toString(), + ); + await prefs.setString( + 'pickuplog_${orderId}_userid', + (base['userid'] ?? 0).toString(), + ); + await prefs.setString('pickuplog_${orderId}_orderid', orderId); + await prefs.setString( + 'pickuplog_${orderId}_orderstatus', + (base['orderstatus'] ?? 'picked').toString(), + ); + } catch (_) {} + + await _postPickupLog(orderId); + final interval = await _getLogInterval(); + _pickupTimers[orderId]?.cancel(); + _pickupTimers[orderId] = Timer.periodic(interval, (_) { + _postPickupLog(orderId); + }); + } + + Future _postPickupLog(String orderId) async { + try { + Map? base = _pickupBasePayload[orderId]; + if (base == null) { + try { + final prefs = await SharedPreferences.getInstance(); + base = { + 'logid': 0, + 'tenantid': + int.tryParse( + prefs.getString('pickuplog_${orderId}_tenantid') ?? '0', + ) ?? + 0, + 'partnerid': + int.tryParse( + prefs.getString('pickuplog_${orderId}_partnerid') ?? '0', + ) ?? + 0, + 'locationid': + int.tryParse( + prefs.getString('pickuplog_${orderId}_locationid') ?? '0', + ) ?? + 0, + 'orderheaderid': + int.tryParse( + prefs.getString('pickuplog_${orderId}_orderheaderid') ?? '0', + ) ?? + 0, + 'pickupid': + int.tryParse( + prefs.getString('pickuplog_${orderId}_pickupid') ?? '0', + ) ?? + 0, + 'userid': + int.tryParse( + prefs.getString('pickuplog_${orderId}_userid') ?? '0', + ) ?? + 0, + 'orderid': + prefs.getString('pickuplog_${orderId}_orderid') ?? orderId, + 'orderstatus': + prefs.getString('pickuplog_${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'), + }; + + + await _pickupLogProvider.createPickupLog(payload); + } catch (e) { + debugPrint('[COLLECTIONLOG][POST] error: $e'); + } + } + + 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); + } + } + + // ==================== BULK ACTION ==================== + String _getBulkButtonLabel() { + final selectedOrderIds = _selectedOrders.entries + .where((e) => e.value) + .map((e) => e.key) + .toList(); + + if (selectedOrderIds.isEmpty) return "Accept Bookings"; + + final statuses = selectedOrderIds + .map((id) => _getStatusFromOrder(_ordersMap[id] ?? {})) + .toSet(); + + if (statuses.length == 1 && statuses.first == 'ACCEPT') { + return "Accept Bookings"; + } + 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 Bookings"; + } else if (statuses.contains('ACCEPTED')) { + return "Move to Arrived"; + } else { + return "Move to Picked"; + } + } + + /// Today's stops, grouped into the trips the hub manager actually assigned. + /// + /// 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 + /// figures are computed from it: strip them here and "2 of 6 stops done" + /// becomes "0 of 4", and the ring empties itself every time the rider + /// finishes something. + List get _trips => Trip.groupIntoTrips( + _allTripStops, + hubLat: _lastGeocodedPosition?.latitude ?? _riderLat, + hubLng: _lastGeocodedPosition?.longitude ?? _riderLng, + ); + + /// Ticked stops that are still *this* trip's and still undecided. + /// + /// Deliberately not just `_selectedStopIds`. That set can outlive the thing + /// it points at in two ways, and the floating [SelectionBar] would report + /// both as work waiting to be accepted: + /// + /// • **A stop stops being pending.** Accepting or rejecting one on its own + /// row leaves the tick behind, so the count would keep including a stop the + /// rider has already decided. + /// • **The rider changes tab.** Selection belongs to one trip; a tick made on + /// Trip 1 must not be counted while Trip 2 is on screen — see the + /// `_selectedStopIds` reset in the tab's `onSelect`. + /// + /// 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); + } + + int get _selectionCount => _selectedPendingIds().length; + + /// Accepts exactly the stops the rider ticked. + /// + /// The middle ground between one-at-a-time and all-at-once: on a long trip + /// he can scan the list, tick the four addresses he knows are open, and take + /// them in a single action instead of eight taps. + Future _acceptSelectedStops(List orderIds) async { + final ids = orderIds.where((id) => _ordersMap.containsKey(id)).toList(); + if (ids.isEmpty || _acceptingTripId != null) return; + + setState(() { + _acceptingTripId = 'selection'; + _selectedOrders = {for (final id in _orderIds) id: ids.contains(id)}; + }); + + try { + await _handleBulkAction(navigateToBookings: false); + } finally { + if (mounted) { + setState(() { + _acceptingTripId = null; + _selectedStopIds = {..._selectedStopIds}..removeAll(ids); + }); + } + } + } + + void _toggleStopSelection(String orderId) { + setState(() { + final next = {..._selectedStopIds}; + next.contains(orderId) ? next.remove(orderId) : next.add(orderId); + _selectedStopIds = next; + }); + } + + /// Declines exactly the stops the rider ticked, after asking why once. + /// + /// The mirror of [_acceptSelectedStops], and it works the same way: one + /// deliberate press on a set he assembled himself, rather than a control on + /// every row. One reason covers the batch — a rider ticking four stops is + /// declining them for one cause ("all past my area", "no time left"), and + /// asking four times would make him pick something, anything, four times. + Future _rejectSelectedStops(List orderIds) async { + final stops = orderIds + .map((id) => _ordersMap[id]) + .whereType>() + .toList(); + if (stops.isEmpty || _acceptingTripId != null) return; + + final reason = await _askRejectReason(stops.length); + if (reason == null || !mounted) return; + + setState(() => _acceptingTripId = 'selection'); + try { + for (final stop in stops) { + await _commitRejection( + stop, + reason: reason, + message: '', + notify: false, + ); + } + if (!mounted) return; + setState(() { + _selectedStopIds = {..._selectedStopIds}..removeAll(orderIds); + }); + _showStopSnack( + stops.length == 1 + ? 'Stop rejected — the hub has been told' + : '${stops.length} stops rejected — the hub has been told', + ColorConstants.secondaryText, + ); + await _fetchQueues(); + } finally { + if (mounted) setState(() => _acceptingTripId = null); + } + } + + /// Reject reasons, phrased as things that actually happen on a route. + /// + /// A reason is required because the hub needs to know whether to re-route the + /// stop to another miler or call the customer — "rejected" with no cause is an + /// operational dead end. + Future _askRejectReason(int count) async { + const reasons = [ + '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', + ]; + + 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), + MilerButton( + label: count == 1 ? 'Keep this stop' : 'Keep them', + variant: MilerButtonVariant.tonal, + color: ColorConstants.acceptGreen, + onPressed: () => Navigator.pop(ctx, null), + ), + ], + ), + ), + ), + ); + } + + /// Returns a rejected stop to undecided. + /// + /// Not an accept: it goes back to the top of the screen as a tickable stop, + /// and it is taken the same way as everything else — ticked, then accepted + /// from the bar. A row control that quietly committed work would be the + /// per-stop Accept button coming back through the side door. + Future _unrejectStop(Map stop) async { + final orderId = (stop['orderid'] ?? '').toString(); + if (orderId.isEmpty || _busyStopIds.contains(orderId)) return; + + setState(() => _busyStopIds.add(orderId)); + try { + await removeRejectedOrderIds([orderId]); + if (!mounted) return; + setState(() { + _rejectedIds = {..._rejectedIds}..remove(orderId); + _hiddenOrderIds.remove(orderId); + stop['orderstatus'] = ''; + }); + _showStopSnack( + 'Back in the list — tick it to take it', + ColorConstants.secondaryText, + ); + await _fetchQueues(); + } finally { + if (mounted) setState(() => _busyStopIds.remove(orderId)); + } + } + + /// Takes back the acceptance the rider just made, from the UNDO on its + /// snackbar. + /// + /// A rejection on the wire, not a silent local rollback: the accept already + /// went to the hub, so those stops are assigned to this miler until something + /// says otherwise. No reason sheet — he is correcting a tap he made seconds + /// ago, and interrupting an undo with a questionnaire is how you make people + /// stop using undo. The reason is stated for him so the hub can still tell + /// this apart from a considered refusal. + /// + /// One snackbar and one refetch for the whole batch, which is why + /// [_commitRejection] takes [notify]. + Future _undoAcceptedBatch(List> stops) async { + final taken = stops + .where((s) => _acceptedIds.contains((s['orderid'] ?? '').toString())) + .toList(); + if (taken.isEmpty) return; + + for (final stop in taken) { + await _commitRejection( + stop, + reason: 'Accepted by mistake — undone by the miler', + message: '', + notify: false, + ); + } + + if (!mounted) return; + // Undo is pressed from the Bookings tab, because that is where the accept + // just sent him. Put him back where the stop went, or he is left looking at + // a list that silently lost a row. + BottomPage.goToTab(0); + _showStopSnack( + taken.length == 1 + ? 'Acceptance undone — the stop is back with the hub' + : '${taken.length} acceptances undone — back with the hub', + ColorConstants.secondaryText, + ); + await _fetchQueues(); + } + + /// Tell the hub, drop the local acceptance, record the rejection, say so. + /// + /// [notify] off suppresses the per-stop snackbar and refetch so a batch can + /// do both once at the end. + Future _commitRejection( + Map stop, { + required String reason, + required String message, + bool notify = true, + }) async { + final orderId = (stop['orderid'] ?? '').toString(); + if (orderId.isEmpty || _busyStopIds.contains(orderId)) return; + + setState(() => _busyStopIds.add(orderId)); + try { + final dc = Get.isRegistered() + ? Get.find() + : Get.put(PickupsController(), permanent: true); + + bool success = false; + try { + success = await dc.updateRejectedStatus( + pickupId: int.tryParse('${stop['pickupid'] ?? 0}') ?? 0, + orderHeaderId: int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0, + notes: reason, + ); + } catch (e) { + debugPrint('[STOP_REJECT] $orderId failed: $e'); + } + + await addRejectedOrderIds([orderId]); + if (success) dc.triggerRefresh(); + + if (!mounted) return; + await removeAcceptedBookings([orderId]); + if (!mounted) return; + setState(() { + _rejectedIds = {..._rejectedIds, orderId}; + _acceptedIds = {..._acceptedIds}..remove(orderId); + _selectedStopIds = {..._selectedStopIds}..remove(orderId); + _hiddenOrderIds.add(orderId); + _ordersMap.remove(orderId); + _orderIds.remove(orderId); + _selectedOrders.remove(orderId); + }); + if (notify) { + _showStopSnack(message, ColorConstants.secondaryText); + await _fetchQueues(); + } + } finally { + if (mounted) setState(() => _busyStopIds.remove(orderId)); + } + } + + /// One-line confirmation, optionally carrying the action that reverses what + /// it is confirming. + /// + /// 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. + void _showStopSnack( + String message, + Color color, { + String? actionLabel, + VoidCallback? onAction, + Duration duration = const Duration(seconds: 4), + }) { + 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, + ), + ), + 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, + ), + ), + ); + } + + /// 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. + List> _lastAcceptedBatch = const []; + + Future _handleBulkAction({bool navigateToBookings = true}) async { + 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'; + } + + if (targetStatus == 'ARRIVED') { + final proximityResults = await _checkProximityForOrders(selectedOrderIds) + .timeout( + const Duration(seconds: 3), + onTimeout: () => Map.fromEntries( + selectedOrderIds.map((id) => MapEntry(id, true)), + ), + ); + + final farOrders = proximityResults.entries + .where((e) => !e.value) + .map((e) => e.key) + .toList(); + + if (farOrders.isNotEmpty) { + if (mounted) { + await _showProximityWarning( + context, + specificMessage: farOrders.length == selectedOrderIds.length + ? 'You must be within 500 meters of the pickup location(s) to mark bookings as arrived.' + : 'Some selected bookings are too far from their pickup locations.', + ); + } + return; + } + } + + XFile? bulkPhoto; + if (targetStatus == 'PICKED') { + try { + final ImagePicker picker = ImagePicker(); + bulkPhoto = await picker.pickImage( + source: ImageSource.camera, + imageQuality: 50, + ); + if (bulkPhoto == null) return; + } catch (e) { + debugPrint('[BULK] Camera error: $e'); + return; + } + } + + showAppDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.black.withValues(alpha: 0.3), + builder: (ctx) => Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + SizedBox(height: 14.h), + Text( + 'Updating ${selectedOrderIds.length} bookings...', + style: TextStyle( + color: _textSecondary, + fontSize: 14.sp, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ); + + bool bulkSucceeded = false; + int successCount = 0; + try { + final dc = Get.isRegistered() + ? Get.find() + : Get.put(PickupsController(), permanent: true); + + // The rider is physically at one place for the whole batch, so resolve + // the current location once and reuse it for every booking's update. + final bulkRiderLoc = await _getValidCoordinates(); + final bulkRidersLat = bulkRiderLoc?.$1 ?? '0'; + final bulkRidersLng = bulkRiderLoc?.$2 ?? '0'; + + // A bulk pick captures a single proof photo; upload it once and attach the + // resulting URL to each picked booking below. + 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, + ); + } + } catch (e) { + debugPrint('[BULK] Upload error: $e'); + } + } + + final List> acceptedBookings = []; + + for (final orderId in selectedOrderIds) { + final Booking = _ordersMap[orderId]; + if (Booking == null) continue; + + final pickupId = int.tryParse('${Booking['pickupid'] ?? 0}') ?? 0; + final orderHeaderId = + int.tryParse('${Booking['orderheaderid'] ?? 0}') ?? 0; + final pickupLocationId = + int.tryParse('${Booking['pickuplocationid'] ?? 0}') ?? 0; + final pickupLat = _parseDouble( + Booking['pickuplat'] ?? Booking['PickupLat'] ?? 0, + ).toString(); + final pickupLng = _parseDouble( + Booking['pickuplon'] ?? Booking['PickupLon'] ?? 0, + ).toString(); + + bool success = false; + try { + if (targetStatus == 'ACCEPTED') { + debugPrint('[BULK] Accepting Booking $orderId'); + success = await dc.updateAcceptedStatus( + pickupId: pickupId, + orderHeaderId: orderHeaderId, + 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); + } else if (targetStatus == 'ARRIVED') { + debugPrint('[BULK] Arriving Booking $orderId'); + success = await dc.updateArrivedStatus( + pickupId: pickupId, + orderHeaderId: orderHeaderId, + ridersLat: bulkRidersLat, + ridersLng: bulkRidersLng, + pickupLat: pickupLat, + pickupLng: pickupLng, + ); + } else if (targetStatus == 'PICKED') { + debugPrint('[BULK] Picking Booking $orderId'); + success = await dc.updatePickedStatus( + pickupId: pickupId, + orderHeaderId: orderHeaderId, + pickupLocationId: pickupLocationId, + ridersLat: bulkRidersLat, + ridersLng: bulkRidersLng, + pickupLat: pickupLat, + pickupLng: pickupLng, + proofImage: bulkProofImageUrl, + ); + if (success) { + _startPickupPosting(Booking); + _hiddenOrderIds.add(orderId); + // Picked → remove from the accepted store so it stops counting as + // an open booking on the Bookings tab, and record it as finished + // so it lands on Activity. Both stores, or the stop falls out of + // the app entirely. + await removeAcceptedBookings([orderId]); + await addCompletedBookings([Booking]); + } + } + } catch (e) { + debugPrint('[BULK] Error updating Booking $orderId: $e'); + success = false; + } + + if (success) { + successCount++; + } else { + debugPrint('[BULK] Failed to update Booking $orderId'); + } + } + + // Persist accepted bookings so the Bookings tab can show them and Home + // keeps them filtered out. + if (acceptedBookings.isNotEmpty) { + await addAcceptedBookings(acceptedBookings); + } + + _lastAcceptedBatch = List>.from(acceptedBookings); + + setState(() { + for (final b in acceptedBookings) { + final id = (b['orderid'] ?? '').toString(); + _ordersMap.remove(id); + _orderIds.remove(id); + } + _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; + await _fetchQueues(); + + // 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') { + final total = selectedOrderIds.length; + final allOk = successCount == total; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + allOk + ? 'Updated $successCount ${total == 1 ? 'booking' : 'bookings'} successfully' + : 'Updated $successCount of $total bookings — the rest could not be updated', + style: const TextStyle( + fontFamily: FontConstants.fontFamily, + color: Colors.white, + ), + ), + backgroundColor: allOk ? _primaryRed : ColorConstants.warning, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + ), + ); + } + } catch (e) { + debugPrint('[BULK] Error: $e'); + } finally { + if (mounted && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + } + + // ── Accepting takes the rider to the work ── + // + // This used to stop at a snackbar reading "work it from the Bookings tab", + // leaving him on Home watching the stop he had just taken disappear off the + // screen. From where he was standing the accept had deleted his booking: + // the row was gone from Home, nothing else on screen had changed, and the + // tab that did have it was one he had to know to go and open — where, for + // up to three seconds, the poll had not landed yet either, so it *was* + // genuinely absent when he arrived. + // + // So the accept now does what it says: it moves him, and it takes the data + // with him. [PickupsController.triggerRefresh] makes Bookings re-fetch on + // the spot rather than at the next tick of its poller, and the switch is an + // IndexedStack index change — the same ScaffoldMessenger, so the UNDO + // snackbar below travels with him and is still there to be pressed. + if (bulkSucceeded && targetStatus == 'ACCEPTED' && !navigateToBookings) { + final taken = _lastAcceptedBatch; + final n = taken.length; + + try { + (Get.isRegistered() + ? Get.find() + : Get.put(PickupsController(), permanent: true)) + .triggerRefresh(); + } catch (e) { + debugPrint('[BULK] Could not poke Bookings to refresh: $e'); + } + BottomPage.goToTab(1); + + // ── The undo window ── + // + // Accepting is now one deliberate press on a set the rider assembled by + // ticking, so a mis-tap is rarer than it was with a button on every row — + // 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. + _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'); + Get.offAll(() => const BottomPage(initialIndex: 1)); + } + } + + /// The whole route in one object: load, distance, time windows, shift. + /// + /// Recomputed each build \u2014 it's a cheap pass over the pending stops, and the + /// shift countdown has to stay honest as the day burns down. + RouteBrief get _routeBrief => RouteBrief.from( + orderIds: _orderIds, + ordersMap: _ordersMap, + shiftStart: _shiftStart, + shiftEnd: _shiftEnd, + extraStops: _acceptedStops, + riderLat: _lastGeocodedPosition?.latitude ?? _riderLat, + riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, + ); + + /// One configured [TripCard] for the selected trip. + /// + /// Home does not render the card as a single widget any more: its summary, its + /// stop list and its action bar go into the sliver list separately so the + /// Trip 1 / 2 / 3 bar can be *pinned* between the summary and the stops. This + /// holds the configuration \u2014 every callback and every id set \u2014 in one place, so + /// the three sections cannot drift apart. See the note above the section + /// getters in `trip_card.dart`. + TripCard _tripCardFor(Trip trip) => TripCard( + key: ValueKey('trip_${trip.id}'), + trip: trip, + acceptedIds: _acceptedIds, + rejectedIds: _rejectedIds, + // Shift window, pace and cash — the summary strip shows the shift and + // the trip's own slot as two labelled rows, which is the whole reason + // the page has to hand this down. + brief: _routeBrief, + busyStopIds: _busyStopIds, + selectedIds: _selectedStopIds, + onToggleSelect: _toggleStopSelection, + onSelectAll: (ids) => setState(() => _selectedStopIds = ids.toSet()), + onAcceptSelected: _acceptSelectedStops, + // The way back for a stop he declined. It does not accept anything by + // itself — see the note at the row. (Resuming a *skipped* stop is + // Activity's control now: a skip is a record of what happened at a + // door, not an offer waiting on a decision.) + onUnrejectStop: _unrejectStop, + // No per-stop or whole-trip accept any more: ticking chooses, the + // floating bar commits. See the decision-row note in `trip_card.dart`. + // Map + full address + everything the card truncates. + onStopTap: (stop) => StopDetailSheet.show( + context, + stop: stop, + stopNumber: trip.stops.indexOf(stop) + 1, + riderLat: _lastGeocodedPosition?.latitude ?? _riderLat, + riderLng: _lastGeocodedPosition?.longitude ?? _riderLng, + ), + onCallStop: (stop) => _callStop(stop), + // 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. + onViewRoute: trip.stops.isEmpty + ? null + : () => openScreen( + context, + pickups.MultiCustomerMapScreen( + pickup: trip.stops, + preservedStepNumbers: { + for (var i = 0; i < trip.stops.length; i++) + (trip.stops[i]['orderid'] ?? '').toString(): i + 1, + }, + ), + swipeToGoBack: false, + ), + ); + + /// \u2500\u2500 The app bar \u2014 pinned, never scrolls away \u2500\u2500 + /// + /// This used to be a `SliverToBoxAdapter` at the top of the scroll view, so + /// the rider's identity *and the ON/OFF duty toggle* slid off the screen as + /// soon as he looked at his second stop. The toggle is the control that + /// governs everything else on the page \u2014 going off duty stops work being + /// assigned \u2014 so needing to scroll back up to reach it was the wrong trade. + /// It is now a sibling above the scroll view, which pins it outright. + /// + /// Only the identity row is pinned. The shift window that used to share this + /// white block scrolls with the content now (see [_buildShiftRow]): it is + /// read-once reference information, and holding it up here would spend ~44 + /// permanent pixels \u2014 on top of the status bar, this bar, the trip tabs and + /// the bottom nav \u2014 to keep a static time range on screen. + Widget _buildAppBar() { + final String firstName = _userName.isNotEmpty + ? _userName.split(' ').first + : "Rider"; + + return AnnotatedRegion( + value: const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + ), + 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, + ), + ), + child: SafeArea( + // Inset handled explicitly below via MediaQuery so the status bar + // never overlaps the header, regardless of nested-Scaffold quirks. + top: false, + bottom: false, + child: Padding( + // Top gap went 8 → 18 → 28. At 8 the greeting sat almost against + // the status bar, which was tolerable while the header scrolled + // away but not now that it is pinned there all day — a fixed bar + // with no air above it reads as clipped rather than as a bar. 18 + // was still tight against a taller left-hand avatar, and it has to + // match the inset [MilerAppBar] uses on the other three tabs or the + // headings jump vertically as the rider switches tab. + // + // Bottom inset is the bar's own breathing room rather than a gap + // before the shift row, so it stays tight at 12. + padding: EdgeInsets.fromLTRB( + 16.w, + MediaQuery.of(context).padding.top + 28.h, + 16.w, + 12.h, + ), + child: Column( + children: [ + // ── Identity row ── + // + // The slot at the left used to hold the Doormile mark, on every + // tab. The logo is gone from all of them now — the rider knows + // which app he opened — and Home is the one screen that has + // something better to put there: him. [MilerAvatar] draws his + // profile photo, or the first letter of his name when he has + // not set one, at exactly the size the mark occupied so the row + // keeps its proportions. + // + // It also gives the greeting a subject: face, then "Hi, Suriya", + // then the duty toggle that governs his shift — one row that is + // entirely about the person using the app. + Row( + children: [ + MilerAvatar(name: _userName), + SizedBox(width: 10.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Greeting + name is the anchor of the screen, so it + // gets the heaviest type in the header. + Text( + "Hi, $firstName", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 23.sp, + fontWeight: FontWeight.w800, + height: 1.1, + letterSpacing: -0.5, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + SizedBox(width: 10.w), + _buildStatusToggle(), + ], + ), + ], + ), + ), + ), + ), + ); + } + + /// ── Duty control ── + /// + /// A switch, because that is what a boolean the rider flips twice a day is. + /// + /// It has been three things. First a skeuomorphic switch — gradient thumb, + /// two drop shadows, a glowing pulse dot — whose thumb slid LEFT for on and + /// RIGHT for off, backwards from every switch ever made. Then a single + /// button, which showed one word and left the rider guessing whether it was + /// his state or the thing tapping would do. Then a two-segment ON | OFF + /// control, which answered that but reads as a form input: two boxes of equal + /// weight, neither of which looks like the live thing it controls. + /// + /// This is the Material 3 switch — a 52×32 track whose fill IS the state, a + /// handle that grows and carries a tick when it lands on, and a word beside + /// it so nothing depends on colour alone. It travels left→right into "on" the + /// way every switch the rider owns does, and the whole row is the target, not + /// just the 52pt track. + /// + /// **Locked** while a pickup is live: going off duty mid-stop would strand a + /// customer. The handle takes a padlock and the row dims rather than the + /// control vanishing, so the state stays readable. + Widget _buildStatusToggle() { + 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; + + return Semantics( + toggled: on, + enabled: !locked, + label: locked + ? 'On duty, locked while a pickup is running' + : 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, + 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, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + /// Shown on a trip tab the hub has not filled yet. + /// + /// The whole thing lives in [TripEmptyState] — including the reasoning for + /// why it is no longer a bordered card. Keyed on the tab so switching from + /// one unassigned slot to another restarts the entrance rather than silently + /// swapping the heading. + Widget _buildTripEmptyState(int index) { + return TripEmptyState( + key: ValueKey('trip-empty-$index'), + tripLabel: Trip.tabLabel(index), + offline: !isOnline, + onGoOnDuty: _confirmOnlineOffline, + ); + } + + Widget _buildEmptyState() { + // 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. + 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, + ), + ), + ), + 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: 8.h), + Text( + offline + ? "Go online to start receiving bookings" + : "We'll notify you when a new booking arrives", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.sp, + 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), + ), + ), + ), + ], + ], + ), + ), + ); + } + + 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; + }, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildActionButton({ + required IconData icon, + required Color color, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 38.w, + height: 38.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Icon(icon, color: color, size: 18.sp), + ), + ); + } + + Widget _buildBulkActionButton() { + final selectedCount = _selectedOrders.values.where((s) => s).length; + if (selectedCount <= 1) return const SizedBox.shrink(); + + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + color: _primaryRed, + boxShadow: [ + BoxShadow( + color: _primaryRed.withValues(alpha: 0.25), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: _hasActivePickup() ? null : _handleBulkAction, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 14.h), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.flash_on_rounded, + color: Colors.white, + size: 20.sp, + ), + SizedBox(width: 8.w), + Text( + '${_getBulkButtonLabel()} (${selectedCount})', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + color: Colors.white, + letterSpacing: 0.3, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } + + // ==================== MAIN BUILD METHOD ==================== + @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); + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + bool shouldExit = true; + try { + final prefs = await SharedPreferences.getInstance(); + final hasLive = prefs.getBool('has_live_pickup') ?? false; + if (hasLive) { + final confirm = await showAppDialog( + context: context, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + title: Text( + 'Pending Pickup', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + color: _textPrimary, + fontWeight: FontWeight.bold, + ), + ), + content: Text( + 'Are you sure you want to close? There are pickups pending.', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + color: _textSecondary, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text( + 'No', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + color: _textSecondary, + ), + ), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text( + 'Yes', + style: TextStyle( + fontFamily: FontConstants.fontFamily, + color: _primaryRed, + ), + ), + ), + ], + ), + ); + shouldExit = confirm == true; + } + } catch (_) {} + if (shouldExit) { + // Home is the root tab — leaving it backgrounds the app. + await SystemNavigator.pop(); + } + }, + child: Scaffold( + backgroundColor: _surfaceColor, + body: Stack( + children: [ + // ── Pinned bar, scrolling route ── + // + // A Column, not a single scroll view: the app bar is the Column's + // first child and the scrollable takes the rest, which is what fixes + // the bar in place. It was previously the first sliver *inside* the + // scroll view, so it scrolled away with everything else — taking the + // duty toggle with it. + Column( + children: [ + _buildAppBar(), + + // ── Pull to refresh ── + // + // Home was the only tab without this. Bookings, Earnings, Cart and + // Notifications all had a RefreshIndicator; here a pull just + // dragged the whole page — header included — down off the top of + // the screen, held it against a blank surface-coloured gap, and + // sprang back having done nothing. That is not a missing feature + // so much as a broken-looking one: the gesture responded, so it + // read as a refresh that had failed. + // + // Physics is half the fix. `BouncingScrollPhysics` forced the iOS + // rubber-band on every platform, and rubber-band overscroll is + // unbounded — hence the gap in the screenshot. Passing plain + // `AlwaysScrollableScrollPhysics` lets `applyTo` compose it with + // the platform default instead (clamping on Android, bouncing on + // iOS), so the overscroll is bounded where it should be, while + // "always scrollable" keeps the pull working on a short list. + // + // `edgeOffset` is gone with the bar: the scroll view now starts + // below it, so the spinner already comes down into clear space + // instead of out from behind the status bar. + Expanded( + 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. + + // ── 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 = {}; + }), + ), + ), + + // 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 ── + // + // 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. + 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(); + } + } + }, + ), + ), + 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. + onTap: () => BottomPage.goToTab(1), + ), + ), + ], + ), + ), + + // The status-bar scrim that used to sit here is gone. It was a white + // strip pinned over the status bar, and it existed for exactly one + // reason: the header scrolled, so content slid up under the clock and + // battery icons and made them unreadable. Now that the app bar is + // pinned — white, and already padded by `MediaQuery.padding.top` — + // nothing can reach that strip, and a second white rectangle painted + // over a white bar every frame is just cost. + ], + ), + ), + ); + } +} + +// ==================== 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'] ?? '-'}", + ), + ], + ), + ), + ], + ], + ), + ), + ); + }, + ); +} + +Widget _buildDetailRow(String label, String value) { + final Color primaryRed = ColorConstants.primary; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + // crossAxisAlignment.start keeps the label top-aligned when a long value + // (e.g. a full pickup address) wraps onto several lines. + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: ColorConstants.onSurfaceVariant, + fontWeight: FontWeight.w500, + fontSize: 14.sp, + fontFamily: FontConstants.fontFamily, + ), + ), + const SizedBox(width: 12), + // Expanded + soft-wrap: the value takes the remaining width and flows + // onto the next line instead of overflowing off the right edge. + Expanded( + child: Text( + value, + textAlign: TextAlign.right, + softWrap: true, + style: TextStyle( + color: + value.contains('Arrived') || + value.contains('Picked') || + value.contains('Accepted') + ? primaryRed + : ColorConstants.onSurface, + fontWeight: FontWeight.w600, + fontSize: 14.sp, + fontFamily: FontConstants.fontFamily, + height: 1.35, + ), + ), + ), + ], + ), + ); +} + +void _showProductDetailsSheet( + BuildContext context, [ + Map? item, +]) { + final Color primaryRed = ColorConstants.primary; + final Color lightRed = ColorConstants.errorLight; + + final data = item ?? const {}; + + String val(List keys) { + for (final k in keys) { + final v = data[k]; + if (v != null && v.toString().trim().isNotEmpty && v.toString() != '0') { + return v.toString().trim(); + } + } + return ''; + } + + // Build the rows that actually have data. + final rows = >[ + MapEntry('Order ID', val(['orderid', 'OrderId'])), + MapEntry( + 'Product', + val(['description', 'productname', 'product', 'tenantname']), + ), + MapEntry('Quantity', val(['Quantity', 'quantity'])), + MapEntry('Weight', () { + final w = val(['weight', 'Weight']); + return w.isEmpty ? '' : (w.contains('kg') ? w : '$w kg'); + }()), + MapEntry('Amount', () { + final a = val(['collectionamt', 'pickupamt']); + return a.isEmpty ? '' : '₹$a'; + }()), + MapEntry('Pickup point', val(['pickupcustomer', 'tenantname'])), + MapEntry('Address:', val(['pickupaddress', 'PickupAddress'])), + MapEntry('Contact', val(['pickupcontactno'])), + MapEntry('Notes', val(['notes', 'Notes'])), + ].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, + ), + ], + ], + ), + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/views/Dashboard/home/homepage_banner.dart b/lib/views/Dashboard/home/homepage_banner.dart new file mode 100644 index 0000000..4af4c44 --- /dev/null +++ b/lib/views/Dashboard/home/homepage_banner.dart @@ -0,0 +1,328 @@ +import 'package:flutter/material.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/widget/Bottom_page.dart'; +import 'package:miler/Models/stop_status.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; + +/// The live strip that sits at the foot of Home and Bookings while a pickup is +/// under way — the rider's way back into the stop he is already on. +/// +/// ── One banner, not two ── +/// +/// Home and Bookings each had their own copy. They had drifted into different +/// widgets: one a gradient card with a double drop shadow, an order-id badge and +/// a status pill; the other a flat red box with a LIVE chip and a hard 40- +/// character address truncation. Same job, same data, two designs — which is how +/// the same screen came to look older on one tab than the other. +/// +/// ── It has to clear the nav bar ── +/// +/// Both copies pinned themselves to `bottom: 0`. The nav bar *floats*: it is a +/// `Positioned` pill in the shell's `Stack`, not a `bottomNavigationBar`, so the +/// Scaffold reserves nothing for it and `SafeArea` cannot see it. The banner +/// therefore sat underneath it, with the bar covering the tap target of the one +/// control telling the rider he has a job open. +/// +/// 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 { + final List> activePickups; + final void Function(Map) onTap; + + const ActivePickupBanner({ + super.key, + required this.activePickups, + required this.onTap, + }); + + @override + State createState() => _ActivePickupBannerState(); +} + +class _ActivePickupBannerState extends State + with SingleTickerProviderStateMixin { + /// The live dot actually pulses. + /// + /// A static dot beside the word LIVE is a claim the interface does not back + /// up; movement is what makes "there is a job running right now" read without + /// being read. Initialised lazily on the declaration rather than in + /// `initState` so hot reload cannot leave it unset — see the note in + /// `Bottom_page.dart`. + /// Created on first *use*, not on first touch of any kind. + /// + /// This was a `late final` initialised on the declaration, which meant + /// `dispose()` calling `_pulse.dispose()` *constructed* the controller during + /// teardown whenever it had never been used — and `build` returns early with + /// no active pickups, so that is the normal path when a rider's last stop + /// finishes and the banner unmounts. `createTicker` then looked up + /// `TickerMode` on a deactivated element and threw. Two of the app's own + /// tests caught it; it needs the nullable so dispose can ask "was there + /// ever one?" instead of answering "there is now". + AnimationController? _pulseController; + + AnimationController get _pulse => _pulseController ??= AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1100), + )..repeat(reverse: true); + + @override + void dispose() { + _pulseController?.dispose(); + super.dispose(); + } + + String _address(Map pickup) => + (pickup['pickupaddress'] ?? + pickup['PickupAddress'] ?? + pickup['address'] ?? + '') + .toString() + .trim(); + + /// Where the rider has got to on this stop — from the one place the app + /// names its statuses, so the banner cannot say "At pickup" while the card + /// behind it says something else about the same stop. See [StopStatusX.label]. + String _status(Map pickup) => stopStatusOf(pickup).label; + + @override + Widget build(BuildContext context) { + if (widget.activePickups.isEmpty) return const SizedBox.shrink(); + + final pickup = widget.activePickups.first; + final count = widget.activePickups.length; + final address = _address(pickup); + + // Rides the floating nav bar: it is `Positioned` outside the Scaffold's + // body, so `SafeArea` knows nothing about it and anything pinned to the + // bottom lands underneath. See [BottomPage.bottomInset]. + // + // A fixed inset now. This used to follow a `navVisible` notifier up and + // down, because the bar left the screen entirely on scroll. It compacts + // instead of leaving, so there is no longer a state in which the room is + // free — and chasing the 20pt difference re-laid this out every time the + // rider changed scroll direction, for a gap he would never notice. + return Padding( + padding: EdgeInsets.only(bottom: BottomPage.bottomInset(context) + 10.h), + child: Padding( + // 16 — the same gutter the nav bar above it uses, so the two stack as + // one column rather than as two floating objects of different widths. + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: _Card( + pulse: _pulse, + count: count, + address: address, + status: _status(pickup), + onTap: () => widget.onTap(pickup), + ), + ), + ); + } +} + +class _Card extends StatelessWidget { + final Animation pulse; + final int count; + final String address; + final String status; + final VoidCallback onTap; + + const _Card({ + required this.pulse, + required this.count, + required this.address, + required this.status, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: ColorConstants.primary, + // 20 — between the stop cards' 16 and the nav bar's 32, which is where it + // sits in the visual stack. + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + clipBehavior: Clip.antiAlias, + // Tinted rather than black: a neutral shadow under a saturated red surface + // reads as grime. The gradient and second shadow the Home copy carried are + // gone — one flat fill and one shadow is the language the rest of the app + // now speaks. + shadowColor: ColorConstants.primary.withValues(alpha: 0.45), + elevation: 10, + child: InkWell( + onTap: onTap, + splashColor: Colors.white.withValues(alpha: 0.10), + highlightColor: Colors.white.withValues(alpha: 0.06), + child: Padding( + padding: EdgeInsets.fromLTRB(12.w, 11.h, 12.w, 11.h), + child: Row( + children: [ + Container( + width: 42.w, + height: 42.w, + decoration: BoxDecoration( + color: ColorConstants.pureSurface.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Icon( + Icons.two_wheeler_rounded, + color: Colors.white, + size: 22.sp, + ), + ), + SizedBox(width: 11.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + _LiveChip(pulse: pulse), + SizedBox(width: 7.w), + // Flexible, not bare: this row has a fixed-width chip on + // one side and a title that grows with the pickup count + // and the text scale on the other. Every unflexed row of + // that shape in this app has overflowed eventually. + Flexible( + child: Text( + count > 1 + ? '$count Active Pickups' + : 'Active Pickup', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 15.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + SizedBox(height: 3.h), + Row( + children: [ + Icon( + Icons.place_rounded, + color: Colors.white.withValues(alpha: 0.72), + size: 13.sp, + ), + SizedBox(width: 3.w), + Expanded( + child: Text( + // Ellipsised by the layout, not chopped at 40 + // characters — the old truncation cut mid-word and + // then appended its own "…", which on a narrow phone + // ellipsised a second time. + address.isEmpty ? status : '$status · $address', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.82), + fontSize: 12.5.sp, + fontWeight: FontWeight.w500, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ], + ), + ), + SizedBox(width: 8.w), + Container( + width: 30.w, + height: 30.w, + decoration: BoxDecoration( + color: ColorConstants.pureSurface.withValues(alpha: 0.16), + shape: BoxShape.circle, + ), + child: Icon( + Icons.arrow_forward_rounded, + color: Colors.white, + size: 16.sp, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// The word LIVE, with a dot that breathes. +class _LiveChip extends StatelessWidget { + final Animation pulse; + + const _LiveChip({required this.pulse}); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.fromLTRB(6.w, 2.5.h, 7.w, 2.5.h), + decoration: BoxDecoration( + color: ColorConstants.pureSurface.withValues(alpha: 0.2), + 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 text beside it every frame. + return SizedBox( + width: 7.w, + height: 7.w, + child: Stack( + alignment: Alignment.center, + children: [ + Transform.scale( + scale: 1.0 + 1.1 * pulse.value, + child: DecoratedBox( + decoration: BoxDecoration( + color: ColorConstants.pureSurface.withValues( + alpha: 0.55 * (1 - pulse.value), + ), + shape: BoxShape.circle, + ), + child: SizedBox(width: 7.w, height: 7.w), + ), + ), + Container( + width: 6.w, + height: 6.w, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + shape: BoxShape.circle, + ), + ), + ], + ), + ); + }, + ), + SizedBox(width: 5.w), + Text( + 'LIVE', + style: TextStyle( + color: Colors.white, + fontSize: 9.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.7, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/Dashboard/home/route_brief.dart b/lib/views/Dashboard/home/route_brief.dart new file mode 100644 index 0000000..ad081c0 --- /dev/null +++ b/lib/views/Dashboard/home/route_brief.dart @@ -0,0 +1,392 @@ +import 'dart:math' as math; + +import 'package:flutter/material.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/design_constants.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// Route brief — the VRP facts a miler needs before touching a single stop. +/// +/// A miler's day is one solved VRP instance: the hub admin has already fixed +/// the sequence, so the rider never re-optimises anything. What he *does* need +/// is to see the shape of the solution he's been handed, because every +/// classic VRP constraint has a real consequence on the bike: +/// +/// • **Capacity (CVRP)** — parcels + weight decide whether the load fits the +/// box. Finding out at stop 9 is too late. +/// • **Time windows (VRPTW)** — each booking has a slot; arriving after it +/// closes is a failed stop. The rider needs to know how many are at risk. +/// • **Route duration limit** — the shift is the hard cap on the route. Time +/// left ÷ stops left is the per-stop budget he's actually racing against. +/// • **Round trip to the depot** — the route ends where it started, so the +/// return leg is part of the distance, not an afterthought. +/// • **Cash on the route** — not a textbook VRP term, but the rider carries +/// it, so it belongs in the same glance. +/// +/// Everything here is derived from data the app already has (stop coordinates, +/// quantities, expected pickup times, the shift window in prefs) — no new +/// endpoint required. +/// ───────────────────────────────────────────────────────────────────────── +class RouteBrief { + /// Stops still to be worked on this route. + final int stops; + + /// Total parcels to be collected — the load against box capacity. + final int parcels; + + /// Total declared weight in kg (0 when the backend doesn't send weights). + final double weightKg; + + /// Cash/UPI still to be collected across the route. + final double collectAmount; + + /// Approximate round-trip length: rider → stop 1 → … → stop N → back to + /// start. Straight-line (haversine), so it under-reads real road distance — + /// always shown with a `≈`. + final double routeKm; + + /// Shift window as stored by the profile sync, e.g. `09:00:00`. + final String shiftStart; + final String shiftEnd; + + /// True only when both ends of the window are present and parseable. + final bool shiftSet; + + /// Time remaining until the shift closes. Null when the shift isn't set; + /// `Duration.zero` when the window has already passed. + final Duration? timeLeft; + + /// Stops whose booked slot has already passed — these are failing their + /// time window right now. + final int overdueStops; + + /// Stops whose slot closes within the next 30 minutes. + final int dueSoonStops; + + const RouteBrief({ + required this.stops, + required this.parcels, + required this.weightKg, + required this.collectAmount, + required this.routeKm, + required this.shiftStart, + required this.shiftEnd, + required this.shiftSet, + required this.timeLeft, + required this.overdueStops, + required this.dueSoonStops, + }); + + /// A brief that knows nothing — no shift, no load, no pace. + /// + /// For callers that render a trip without the page-level context, chiefly + /// [TripCard] used as a whole widget in tests. `shiftSet: false` is the + /// meaningful part: every consumer already treats that as "say nothing about + /// time", so an absent brief degrades to the same state as an unset shift + /// rather than needing a null check at each use. + static const RouteBrief empty = RouteBrief( + stops: 0, + parcels: 0, + weightKg: 0, + collectAmount: 0, + routeKm: 0, + shiftStart: '', + shiftEnd: '', + shiftSet: false, + timeLeft: null, + overdueStops: 0, + dueSoonStops: 0, + ); + + /// Average time available per remaining stop — the pace the rider has to + /// hold to finish inside the shift. Null when either half is unknown. + Duration? get perStopBudget { + final left = timeLeft; + if (left == null || stops <= 0 || left <= Duration.zero) return null; + return Duration(seconds: left.inSeconds ~/ stops); + } + + /// True when the remaining stops can't realistically fit in the time left + /// (under 6 minutes a stop, which is faster than a pickup can be done). + bool get isPaceAtRisk { + final budget = perStopBudget; + return budget != null && budget.inMinutes < 6; + } + + String get shiftWindowLabel { + if (!shiftSet) return 'Shift not set'; + return '${_prettyTime(shiftStart)} – ${_prettyTime(shiftEnd)}'; + } + + /// Builds the brief from the pending-stop map the Home page already holds. + /// + /// [extraStops] are stops that have left the pending list but are still on + /// the route (accepted, awaiting pickup) — they count towards the load, + /// distance and time-window totals just like pending ones. + /// + /// [riderLat]/[riderLng] are optional: without them the route length falls + /// back to chaining the stops themselves (and to the per-stop `kms` field + /// the backend sends, when coordinates are missing altogether). + factory RouteBrief.from({ + required List orderIds, + required Map> ordersMap, + required String shiftStart, + required String shiftEnd, + List> extraStops = const [], + double? riderLat, + double? riderLng, + DateTime? now, + }) { + final clock = now ?? DateTime.now(); + + int parcels = 0; + double weight = 0; + double collect = 0; + int overdue = 0; + int dueSoon = 0; + double fallbackKm = 0; + + final points = <({double lat, double lng})>[]; + + final allStops = >[ + for (final id in orderIds) + if (ordersMap[id] != null) ordersMap[id]!, + ...extraStops, + ]; + + for (final stop in allStops) { + parcels += _toInt(stop['Quantity'] ?? stop['quantity']); + weight += _toDouble(stop['weight'] ?? stop['Weight']); + collect += _toDouble(stop['collectionamt'] ?? stop['pickupamt']); + fallbackKm += _toDouble(stop['kms'] ?? stop['km']); + + final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']); + final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']); + if (lat != 0 && lng != 0) points.add((lat: lat, lng: lng)); + + final due = _parseTimestamp( + stop['expected_pickup_time'] ?? + stop['expectedpickuptime'] ?? + stop['slotendtime'], + ); + if (due != null) { + if (due.isBefore(clock)) { + overdue++; + } else if (due.difference(clock) <= const Duration(minutes: 30)) { + dueSoon++; + } + } + } + + // Round trip: start at the rider (his current position stands in for the + // hub he left), walk the fixed sequence, then close the loop back to it. + double routeKm = 0; + final ({double lat, double lng})? origin = + (riderLat != null && riderLng != null && riderLat != 0 && riderLng != 0) + ? (lat: riderLat, lng: riderLng) + : null; + if (points.isNotEmpty) { + final chain = <({double lat, double lng})>[ + if (origin != null) origin, + ...points, + if (origin != null) origin, + ]; + for (var i = 0; i < chain.length - 1; i++) { + routeKm += _haversineKm(chain[i], chain[i + 1]); + } + } + if (routeKm == 0) routeKm = fallbackKm; + + final start = _parseClock(shiftStart, clock); + final end = _parseClock(shiftEnd, clock); + final shiftSet = start != null && end != null; + + Duration? left; + if (shiftSet) { + // Overnight shifts (22:00 → 06:00) roll the end into tomorrow. + final adjustedEnd = end.isBefore(start) + ? end.add(const Duration(days: 1)) + : end; + final diff = adjustedEnd.difference(clock); + left = diff.isNegative ? Duration.zero : diff; + } + + return RouteBrief( + stops: allStops.length, + parcels: parcels, + weightKg: weight, + collectAmount: collect, + routeKm: routeKm, + shiftStart: shiftStart, + shiftEnd: shiftEnd, + shiftSet: shiftSet, + timeLeft: left, + overdueStops: overdue, + dueSoonStops: dueSoon, + ); + } + + // ── parsing helpers ──────────────────────────────────────────────────── + static int _toInt(dynamic v) => + v == null ? 0 : int.tryParse(v.toString().trim()) ?? 0; + + static double _toDouble(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toDouble(); + // Strip units/currency the backend sometimes bakes into the string. + final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), ''); + return double.tryParse(cleaned) ?? 0; + } + + static DateTime? _parseTimestamp(dynamic v) { + if (v == null) return null; + return DateTime.tryParse(v.toString().trim()); + } + + /// Parses `HH:mm[:ss]` or `h:mm AM/PM` onto [onDay]. + static DateTime? _parseClock(String raw, DateTime onDay) { + final value = raw.trim(); + if (value.isEmpty) return null; + + final match = RegExp( + r'^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])?$', + ).firstMatch(value); + if (match == null) return null; + + var hour = int.tryParse(match.group(1) ?? '') ?? -1; + final minute = int.tryParse(match.group(2) ?? '') ?? -1; + final second = int.tryParse(match.group(3) ?? '0') ?? 0; + if (hour < 0 || minute < 0 || minute > 59) return null; + + final meridiem = match.group(4)?.toUpperCase(); + if (meridiem == 'PM' && hour < 12) hour += 12; + if (meridiem == 'AM' && hour == 12) hour = 0; + if (hour > 23) return null; + + return DateTime(onDay.year, onDay.month, onDay.day, hour, minute, second); + } + + static String _prettyTime(String raw) { + final parsed = _parseClock(raw, DateTime.now()); + if (parsed == null) return raw.trim(); + final hour12 = parsed.hour % 12 == 0 ? 12 : parsed.hour % 12; + final minute = parsed.minute.toString().padLeft(2, '0'); + return '$hour12:$minute ${parsed.hour < 12 ? 'AM' : 'PM'}'; + } + + static double _haversineKm( + ({double lat, double lng}) a, + ({double lat, double lng}) b, + ) { + const earthRadiusKm = 6371.0; + final dLat = _rad(b.lat - a.lat); + final dLng = _rad(b.lng - a.lng); + final h = + math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_rad(a.lat)) * + math.cos(_rad(b.lat)) * + math.sin(dLng / 2) * + math.sin(dLng / 2); + return earthRadiusKm * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h)); + } + + static double _rad(double deg) => deg * math.pi / 180.0; +} + +/// Formats a duration the way a rider reads a clock: `2h 15m`, `45m`. +String formatDuration(Duration d) { + if (d.inMinutes < 1) return 'under a minute'; + if (d.inHours < 1) return '${d.inMinutes}m'; + final minutes = d.inMinutes.remainder(60); + return minutes == 0 ? '${d.inHours}h' : '${d.inHours}h ${minutes}m'; +} + +/// ───────────────────────────────────────────────────────────────────────── +/// Shift banner — the assigned shift window, and nothing else. +/// +/// One state. When the hub has set a shift this is a quiet informational strip: +/// confirmation, not news, so it stays visually silent and never competes with +/// the stop list. When no shift is set the widget renders **nothing**. +/// +/// ── Why the "No shift set for today" box is gone ── +/// +/// It had already been de-escalated once, from a pulsing hazard-triangle alert +/// down to a calm amber row, and the reasoning that drove that step applies all +/// the way to zero: the rider cannot fix a missing shift, did not cause it, and +/// is not blocked by it — the trips below still load and he can still ride +/// them. The row's own subtitle admitted as much ("your hub manager sets it · +/// trips still work"). +/// +/// So it was a permanent amber box, at the top of the first screen, telling the +/// rider about someone else's unfinished admin. Its Retry button could only +/// re-read the same local prefs, because there is no endpoint to re-pull the +/// window — so the one affordance it offered mostly did nothing. Removing it +/// costs the rider no information he can act on and gives the route back the +/// top of the screen. +/// +/// The window still governs duty close and the shift-end alarm; it is simply +/// not this widget's job to editorialise when it is absent. +/// ───────────────────────────────────────────────────────────────────────── +class ShiftBanner extends StatelessWidget { + final RouteBrief brief; + + const ShiftBanner({super.key, required this.brief}); + + @override + Widget build(BuildContext context) { + if (!brief.shiftSet) return const SizedBox.shrink(); + return _buildSetState(); + } + + // ── Shift assigned: quiet, informational ─────────────────────────────── + Widget _buildSetState() { + final left = brief.timeLeft; + final ended = left != null && left == Duration.zero; + + return Container( + padding: EdgeInsets.symmetric(vertical: 9.h, horizontal: 12.w), + decoration: BoxDecoration( + color: ColorConstants.surfaceContainerLow, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + border: Border.all(color: ColorConstants.borderStrong, width: 1.5), + ), + child: Row( + children: [ + Icon( + Icons.access_time_rounded, + 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, + fontFamily: FontConstants.fontFamily, + ), + ), + const Spacer(), + if (left != null) + Text( + ended ? 'Shift ended' : '${formatDuration(left)} left', + style: TextStyle( + color: ended + ? ColorConstants.error + : (brief.isPaceAtRisk + ? ColorConstants.warning + : ColorConstants.secondaryText), + fontSize: 11.5.sp, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/Dashboard/home/selection_bar.dart b/lib/views/Dashboard/home/selection_bar.dart new file mode 100644 index 0000000..ac1866e --- /dev/null +++ b/lib/views/Dashboard/home/selection_bar.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.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'; +import 'package:miler/widget/Bottom_page.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// SELECTION BAR — the contextual action for a multi-stop pick +/// +/// Appears the moment the rider ticks a stop and leaves when he unticks the +/// last one. While it is up it owns the bulk action: the inline "Accept all N +/// stops" button in the trip's footer stands down, so there is never a choice +/// of two bulk buttons pointing at different sets of stops. +/// +/// ── Why this floats instead of sitting in the list ── +/// +/// It used to be an inline button in the footer, below the stop rows. That put +/// a *response to a direct action* at the bottom of a scroll: tick a stop near +/// the top of a six-stop route and the button confirming what you had just done +/// was off-screen. The rider had no feedback that the tick had registered +/// beyond the tick itself, and no way to act on it without scrolling past every +/// remaining stop. +/// +/// A selection is transient and it is *modal in spirit* — the screen is in a +/// different mode while it is non-empty. Every app that does multi-select the +/// way riders already know it, Gmail and Google Photos included, answers that +/// with a contextual bar pinned to the edge of the viewport: it appears with +/// the selection, it is always reachable, and it leaves without a trace. The +/// count lives on it, so the rider never has to go looking for how many he has +/// picked. +/// +/// ── Clearing ── +/// +/// The ✕ is on the left, where a dismiss belongs and where it cannot be hit by +/// a thumb going for Accept. Without it the only way out of a selection was to +/// untick each stop by hand, which on a six-stop route is six taps to undo one +/// mistake. +/// ───────────────────────────────────────────────────────────────────────── +class SelectionBar extends StatelessWidget { + /// How many stops are ticked. Zero renders nothing. + final int count; + + /// Accepts exactly the ticked stops. + final VoidCallback? onAccept; + + /// Declines exactly the ticked stops, after asking why. Null hides the + /// action. + final VoidCallback? onReject; + + /// Drops the whole selection. + final VoidCallback onClear; + + /// True while the accept is in flight. + final bool busy; + + const SelectionBar({ + super.key, + required this.count, + required this.onAccept, + required this.onClear, + this.onReject, + this.busy = false, + }); + + @override + Widget build(BuildContext context) { + // Rides the floating nav bar: it is `Positioned` outside the Scaffold's + // body, so `SafeArea` knows nothing about it and anything pinned to the + // bottom lands underneath. See [BottomPage.bottomInset]. + // + // A fixed inset now. This used to follow a `navVisible` notifier up and + // down, because the bar left the screen entirely on scroll. It compacts + // instead of leaving, so there is no longer a state in which the room is + // free — and chasing the 20pt difference re-laid this out every time the + // rider changed scroll direction, for a gap he would never notice. + return Padding( + padding: EdgeInsets.only(bottom: BottomPage.bottomInset(context) + 10.h), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + // A real lift, not the page's usual whisper: this is the one surface + // on Home that is temporarily above everything else, and it has to + // read that way against a list of white cards. + elevation: 0, + child: Container( + padding: EdgeInsets.fromLTRB(6.w, 8.h, 10.w, 8.h), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: ColorConstants.borderSubtle), + boxShadow: DesignConstants.shadowLg, + ), + 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, + ), + ), + ) + 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, + ), + ), + 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, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ClearButton extends StatelessWidget { + final VoidCallback? onTap; + const _ClearButton({this.onTap}); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'Clear selection', + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + child: SizedBox( + width: ButtonSizes.minTapTarget, + height: ButtonSizes.minTapTarget, + child: Icon( + Icons.close_rounded, + size: 20.sp, + color: ColorConstants.secondaryText, + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/home/stop_detail_sheet.dart b/lib/views/Dashboard/home/stop_detail_sheet.dart new file mode 100644 index 0000000..a7484ef --- /dev/null +++ b/lib/views/Dashboard/home/stop_detail_sheet.dart @@ -0,0 +1,596 @@ +import 'package:flutter/material.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/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/constants/design_constants.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// STOP DETAIL SHEET +/// +/// Opens when the rider taps a stop on the trip card. It answers the two +/// questions the list deliberately does not have room for: +/// +/// "Where actually is this?" → a map with the hub, the stop, and the leg +/// between them +/// "What exactly is here?" → the full address, contact, parcel counts, +/// cash, notes, OTP +/// +/// This is why the card above can afford to truncate. The list is for +/// scanning — one line of identity, two of address — and everything it drops +/// lives here, one tap away. A list that tries to show everything shows +/// nothing well. +/// +/// The map is deliberately small and non-interactive: it is an orientation +/// aid, not a navigation surface. Turn-by-turn is Google Maps' job, and the +/// Navigate button hands off to it. +/// ───────────────────────────────────────────────────────────────────────── +class StopDetailSheet extends StatelessWidget { + final Map stop; + + /// Stop's position in the route, 1-based. + final int stopNumber; + + /// Where the rider is now — draws the "you are here" leg on the map. + final double? riderLat; + final double? riderLng; + + const StopDetailSheet({ + super.key, + required this.stop, + required this.stopNumber, + this.riderLat, + this.riderLng, + }); + + /// Opens the sheet. Kept here so callers do not each re-declare the shape. + static Future show( + BuildContext context, { + required Map stop, + required int stopNumber, + 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)), + ), + builder: (_) => StopDetailSheet( + stop: stop, + stopNumber: stopNumber, + riderLat: riderLat, + riderLng: riderLng, + ), + ); + } + + double _d(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString().trim()) ?? 0; + } + + String _s(List keys) { + for (final k in keys) { + final v = stop[k]; + if (v != null && v.toString().trim().isNotEmpty && v.toString() != '0') { + return v.toString().trim(); + } + } + return ''; + } + + @override + Widget build(BuildContext context) { + final kind = stopKindOf(stop); + final lat = _d(stop['pickuplat'] ?? stop['PickupLat']); + final lng = _d(stop['pickuplon'] ?? stop['PickupLon']); + final hasLocation = lat != 0 && lng != 0; + + final name = _s(['pickupcustomer', 'tenantname']); + final address = _s(['pickupaddress', 'PickupAddress']); + final phone = _s(['pickupcontactno']); + final notes = _s(['notes', 'Notes']); + final otp = stopOtp(stop); + final deliver = deliveryParcelCount(stop); + final collect = pickupParcelCount(stop); + final cash = stopCollectionAmount(stop); + + final meters = RouteMetricsHelper.metersToStop( + stop, + riderLat: riderLat, + riderLng: riderLng, + ); + + return DraggableScrollableSheet( + initialChildSize: 0.78, + 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, + ), + ), + SizedBox(height: 16.h), + ], + _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), + ], + ), + ), + ], + ), + ); + } + + Widget _header(StopKind kind, String name) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 34.w, + height: 34.w, + alignment: Alignment.center, + decoration: BoxDecoration(color: kind.accent, shape: BoxShape.circle), + child: Text( + '$stopNumber', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w800, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 11.w), + Expanded( + 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), + Text( + name.isEmpty ? 'Stop $stopNumber' : name, + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + height: 1.15, + color: ColorConstants.slateText, + 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. + /// + /// ── Why it is not lite mode any more ── + /// + /// It was. Lite mode renders the map once, into a bitmap, at whatever size the + /// view happens to have when the snapshot is taken — and it does not re-render + /// when that size changes. Everything about where this map lives conspires + /// against that: it is built by [AfterEntrance] a beat *after* the sheet has + /// begun to settle, inside a [DraggableScrollableSheet] whose height is still + /// moving, in a list the rider can drag. The snapshot was therefore taken + /// against a box that no longer existed, and what he saw was half a map with + /// dead space under it. + /// + /// A normal map lays out against its box and repaints when the box changes, so + /// the failure is structural rather than a timing race to be tuned. The cost + /// lite mode was buying — not building a platform view during the entrance — + /// 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( + height: 170.h, + // ── Why the map waits ── + // + // This sheet used to arrive with a jolt, and the curve was never the + // problem: a GoogleMap is an Android platform view, and building one + // costs the UI thread a couple of hundred milliseconds. Constructed in + // the same frame the sheet begins to rise, it swallowed most of the + // entrance — the animation ran, but the frames were never drawn, so the + // sheet appeared to snap into place. + // + // [AfterEntrance] holds it until the sheet has finished rising. The + // motion is now spent on cheap widgets, and the map fades up a beat + // 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(), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _distanceLine(double? meters) { + final distance = RouteMetricsHelper.formatDistance(meters); + final travel = RouteMetricsHelper.travelTime(meters); + if (distance == '—') return const SizedBox.shrink(); + + return Row( + children: [ + Icon( + Icons.near_me_rounded, + 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, + ), + ), + if (travel > Duration.zero) ...[ + Text( + ' · ${RouteMetricsHelper.formatDuration(travel)} ride', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ], + ); + } + + Widget _sectionLabel(String text) => Text( + text, + style: TextStyle( + fontSize: 10.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ); + + Widget _actionRow({ + required IconData icon, + required Color color, + required String title, + required String detail, + }) { + return Padding( + padding: EdgeInsets.only(bottom: 8.h), + child: Row( + children: [ + Container( + width: 32.w, + height: 32.w, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Icon(icon, size: 17.sp, color: color), + ), + SizedBox(width: 10.w), + 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, + ), + ), + ], + ), + ); + } + + Widget _detailGrid() { + final rows = >[ + MapEntry('Order', _s(['orderid', 'OrderId'])), + MapEntry('Product', _s(['description', 'productname', 'product'])), + MapEntry('Weight', () { + final w = _s(['weight', 'Weight']); + return w.isEmpty ? '' : (w.contains('kg') ? w : '$w kg'); + }()), + MapEntry('Booked slot', () { + final raw = _s(['expected_pickup_time']); + final t = DateTime.tryParse(raw); + return t == null ? '' : RouteMetricsHelper.formatClock(t); + }()), + MapEntry('Contact', _s(['pickupcontactno'])), + ].where((e) => e.value.isNotEmpty).toList(); + + 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, + ), + ), + ), + Expanded( + child: Text( + r.value, + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ], + ); + } + + Widget _noteBox(String notes) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h), + decoration: BoxDecoration( + color: ColorConstants.warningContainer, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + border: Border.all( + color: ColorConstants.warning.withValues(alpha: 0.35), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.sticky_note_2_rounded, + size: 17.sp, + color: ColorConstants.warning, + ), + SizedBox(width: 9.w), + Expanded( + child: Text( + notes, + style: TextStyle( + fontSize: 13.sp, + height: 1.4, + fontWeight: FontWeight.w700, + color: ColorConstants.onErrorContainer, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } + + Widget _actions( + BuildContext context, + String phone, + double lat, + double lng, + bool hasLocation, + ) { + return Row( + children: [ + if (phone.isNotEmpty) ...[ + Expanded( + child: MilerButton( + label: 'Call', + icon: Icons.call_rounded, + variant: MilerButtonVariant.outlined, + color: ColorConstants.acceptGreen, + onPressed: () => _dial(phone), + ), + ), + SizedBox(width: 10.w), + ], + Expanded( + flex: 2, + child: MilerButton( + label: 'Navigate', + icon: Icons.navigation_rounded, + color: ColorConstants.primary, + onPressed: hasLocation ? () => _navigate(lat, lng) : null, + ), + ), + ], + ); + } + + Future _dial(String phone) async { + final clean = phone.replaceAll(RegExp(r'[^\d+]'), ''); + if (clean.isEmpty) return; + try { + await launchUrl( + Uri(scheme: 'tel', path: clean), + mode: LaunchMode.platformDefault, + ); + } catch (_) {} + } + + Future _navigate(double lat, double lng) async { + // Native intent first, web as the fallback — same order as the stop screen. + 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 (_) {} + } +} diff --git a/lib/views/Dashboard/home/trip.dart b/lib/views/Dashboard/home/trip.dart new file mode 100644 index 0000000..a1c44ba --- /dev/null +++ b/lib/views/Dashboard/home/trip.dart @@ -0,0 +1,752 @@ +import 'dart:math' as math; + +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; +import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// A TRIP — one slot's worth of work, as a single unit. +/// +/// The hub manager does not assign bookings one at a time. He takes every +/// customer booking that falls inside a slot (say 10:00–13:00), orders them +/// into a route, and hands the whole thing to one miler. So the rider's +/// decision is never "do I want this one parcel?" — it is "do I take this +/// trip?". Home used to show loose bookings, which framed the job as a +/// marketplace it isn't, and hid the two facts that actually matter: how long +/// the whole run takes, and where it starts and ends. +/// +/// A trip is therefore always shaped: +/// +/// HUB → Stop 1 → Stop 2 → … → Stop N → HUB +/// +/// with the same hub at both ends. Every duration and distance below includes +/// the return leg, because the rider is not finished until he is back. +/// +/// This class is pure — no Flutter, no I/O — so the arithmetic is unit-tested +/// and safe to call from `build`. +/// ───────────────────────────────────────────────────────────────────────── +class Trip { + /// Stable identity for this trip (backend slot/trip id, or a derived key). + final String id; + + /// The customer-facing slot window this trip serves. Null when the backend + /// sends no usable times — the trip is then simply "today's route". + final DateTime? slotStart; + final DateTime? slotEnd; + + /// Stops in the admin's fixed order. Never re-sorted by the app. + final List> stops; + + /// Straight-line round-trip length in metres, hub → stops → hub. + final double routeMeters; + + /// Sum of the per-stop service estimates (time off the bike). + final Duration serviceDuration; + + /// Time on the bike, at [RouteMetricsHelper.kDefaultSpeedMps]. + final Duration travelDuration; + + final int totalParcels; + final int deliverParcels; + final int collectParcels; + final double totalWeightKg; + final double cashToCollect; + + const Trip({ + required this.id, + required this.slotStart, + required this.slotEnd, + required this.stops, + required this.routeMeters, + required this.serviceDuration, + required this.travelDuration, + required this.totalParcels, + required this.deliverParcels, + required this.collectParcels, + required this.totalWeightKg, + required this.cashToCollect, + }); + + int get stopCount => stops.length; + + /// The number the rider is actually planning around: ride time plus every + /// minute spent standing at a door. + Duration get totalDuration => travelDuration + serviceDuration; + + /// Average time per stop across the whole trip, service only. + Duration get averageStopDuration => stopCount == 0 + ? Duration.zero + : Duration(seconds: serviceDuration.inSeconds ~/ stopCount); + + /// `10:00 AM – 1:00 PM`, or a plain label when the slot is unknown. + String get slotLabel { + final s = slotStart; + final e = slotEnd; + if (s == null || e == null) return "Today's route"; + return '${RouteMetricsHelper.formatClock(s)} – ' + '${RouteMetricsHelper.formatClock(e)}'; + } + + /// True when the slot window has already closed. + bool isExpired([DateTime? now]) { + final e = slotEnd; + if (e == null) return false; + return (now ?? DateTime.now()).isAfter(e); + } + + /// Order ids in this trip. + List get orderIds => [ + for (final s in stops) + if ((s['orderid'] ?? '').toString().isNotEmpty) (s['orderid']).toString(), + ]; + + // ── Per-stop state & completion ──────────────────────────────────────── + + /// State of every stop, in route order. + List states({ + required Set acceptedIds, + required Set rejectedIds, + }) => [ + for (final s in stops) + stopStateOf(s, acceptedIds: acceptedIds, rejectedIds: rejectedIds), + ]; + + /// Ids an "accept whole trip" action should operate on — only the stops + /// still awaiting a decision. Re-accepting one already accepted would be a + /// wasted call, and re-accepting a rejected one would undo the rider. + List pendingOrderIds({ + required Set acceptedIds, + required Set rejectedIds, + }) => [ + for (final s in stops) + if (stopStateOf(s, acceptedIds: acceptedIds, rejectedIds: rejectedIds) == + StopState.pending) + (s['orderid'] ?? '').toString(), + ].where((id) => id.isNotEmpty).toList(); + + /// Fraction of the trip that is finished, 0.0 → 1.0. + /// + /// Counts **resolved** stops — done or rejected — not just completed ones. + /// A rejected stop is off the rider's plate; leaving it out of the numerator + /// would strand the trip below 100% with nothing he could do about it. + double completion({ + required Set acceptedIds, + required Set rejectedIds, + }) { + if (stops.isEmpty) return 0; + final resolved = states( + acceptedIds: acceptedIds, + rejectedIds: rejectedIds, + ).where((s) => s.isResolved).length; + return resolved / stops.length; + } + + /// Completion as a whole percent, for display. + int completionPercent({ + required Set acceptedIds, + required Set rejectedIds, + }) => (completion(acceptedIds: acceptedIds, rejectedIds: rejectedIds) * 100) + .round(); + + /// True once every stop has been resolved — the rider can head back. + bool isFinished({ + required Set acceptedIds, + required Set rejectedIds, + }) => + stops.isNotEmpty && + states( + acceptedIds: acceptedIds, + rejectedIds: rejectedIds, + ).every((s) => s.isResolved); + + /// True while any stop still needs an accept/reject decision. + bool hasPendingStops({ + required Set acceptedIds, + required Set rejectedIds, + }) => states( + acceptedIds: acceptedIds, + rejectedIds: rejectedIds, + ).any((s) => s.needsDecision); + + /// Short tab label — `Trip 1`. + static String tabLabel(int index) => 'Trip ${index + 1}'; + + /// Address components every stop on this trip shares — city, state, + /// pincode and so on. Cached per build by the caller. + List get sharedAddressTail => commonAddressTail([ + for (final s in stops) (s['pickupaddress'] ?? '').toString(), + ]); + + /// The distinguishing part of a stop's address, with the shared tail removed. + String shortAddress(Map stop, List tail) => + stripAddressTail((stop['pickupaddress'] ?? '').toString(), tail); + + // ── Per-stop estimates ───────────────────────────────────────────────── + + /// Estimated time OFF the bike at [index]. + /// + /// Service time is not a constant. Handing a parcel over and reading back an + /// OTP is quick; weighing and photographing a collection is slower; doing + /// both at one door is slower still; and counting cash adds real minutes. + /// These are the numbers the pace warning on Home is measured against, so + /// they are deliberately not optimistic. + static Duration serviceTimeFor(Map stop) { + final kind = stopKindOf(stop); + + var seconds = switch (kind) { + StopKind.delivery => 150, // 2m30 — hand over, OTP, photo + StopKind.pickup => 240, // 4m00 — collect, weigh, photo + StopKind.combined => 330, // 5m30 — both, but one walk-up + }; + + // Cash handling is the single biggest variable at a door. + if (stopCollectionAmount(stop) > 0) seconds += 90; + + // Multi-parcel stops take longer to count and load. First parcel is in + // the base estimate; each extra adds handling time. + final parcels = deliveryParcelCount(stop) + pickupParcelCount(stop); + if (parcels > 1) seconds += (parcels - 1) * 25; + + return Duration(seconds: seconds); + } + + /// 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}) { + 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]); + if (from == null || to == null) return Duration.zero; + + final meters = RouteMetricsHelper.distanceMeters( + from.lat, + from.lng, + to.lat, + to.lng, + ); + return RouteMetricsHelper.travelTime(meters); + } + + /// Running clock estimate of when the rider reaches stop [index], assuming + /// he leaves the hub at [departure]. + DateTime etaForStop( + int index, { + required DateTime departure, + double? hubLat, + double? hubLng, + }) { + var clock = departure; + for (var i = 0; i <= index && i < stops.length; i++) { + clock = clock.add(travelTimeToStop(i, hubLat: hubLat, hubLng: hubLng)); + if (i < index) clock = clock.add(serviceTimeFor(stops[i])); + } + return clock; + } + + ({double lat, double lng})? _origin(double? lat, double? lng) => + (lat != null && lng != null && lat != 0 && lng != 0) + ? (lat: lat, lng: lng) + : null; + + // ── Construction ─────────────────────────────────────────────────────── + + /// Builds one trip from an ordered list of stops. + /// + /// [hubLat]/[hubLng] anchor both ends of the round trip. The backend does + /// not yet send hub coordinates, so callers pass the rider's current + /// position — he is at (or near) the hub when he picks up a trip, which + /// makes it a workable stand-in. When it is missing, the route length is the + /// stop-to-stop chain only and the return leg is simply absent rather than + /// guessed. + factory Trip.fromStops({ + required String id, + required List> stops, + DateTime? slotStart, + DateTime? slotEnd, + double? hubLat, + double? hubLng, + }) { + final ordered = sortStops(stops); + + int parcels = 0; + int deliver = 0; + int collect = 0; + double weight = 0; + double cash = 0; + var service = Duration.zero; + + for (final s in ordered) { + final d = deliveryParcelCount(s); + final c = pickupParcelCount(s); + deliver += d; + collect += c; + parcels += (d + c) > 0 ? (d + c) : 1; + weight += _toDouble(s['weight'] ?? s['Weight']); + cash += stopCollectionAmount(s); + service += serviceTimeFor(s); + } + + // Round trip: hub → every stop in order → back to the hub. + final chain = <({double lat, double lng})>[]; + final origin = + (hubLat != null && hubLng != null && hubLat != 0 && hubLng != 0) + ? (lat: hubLat, lng: hubLng) + : null; + if (origin != null) chain.add(origin); + for (final s in ordered) { + final c = _coordsOf(s); + if (c != null) chain.add(c); + } + if (origin != null && chain.length > 1) chain.add(origin); + + double meters = 0; + for (var i = 0; i < chain.length - 1; i++) { + meters += RouteMetricsHelper.distanceMeters( + chain[i].lat, + chain[i].lng, + chain[i + 1].lat, + chain[i + 1].lng, + ); + } + if (meters == 0) { + // Fall back to whatever per-stop kilometres the backend sent. + for (final s in ordered) { + meters += _toDouble(s['kms'] ?? s['km']) * 1000; + } + } + + return Trip( + id: id, + slotStart: slotStart, + slotEnd: slotEnd, + stops: ordered, + routeMeters: meters, + serviceDuration: service, + travelDuration: RouteMetricsHelper.travelTime(meters), + totalParcels: parcels, + deliverParcels: deliver, + collectParcels: collect, + totalWeightKg: weight, + cashToCollect: cash, + ); + } + + /// Groups loose stops into trips. + /// + /// Grouping key, in order of trust: + /// 1. An explicit backend `tripid` / `slotid` / `routeid`. + /// 2. An explicit slot window (`slotstarttime` + `slotendtime`). + /// 3. The `expected_pickup_time` bucketed into a [windowHours] block + /// aligned to the clock — 10:00–13:00, 13:00–16:00, and so on. This is + /// how the hub builds slots, so bucketing reproduces them closely enough + /// to be useful while the backend has no trip field. + /// 4. Everything else lands in one "today's route" trip rather than being + /// dropped. + /// + /// Returned trips are ordered by slot start, earliest first. + /// The most trips a rider's day can contain. + /// + /// Three is the operational fact, not a display limit: the hub assigns morning, + /// afternoon and evening slots. [TripTabs] renders three slots for the same + /// reason, and [_capToMaxTrips] folds any extra buckets into the last so the + /// two never disagree. + static const int maxTripsPerDay = 3; + + static List groupIntoTrips( + List> stops, { + int windowHours = 3, + double? hubLat, + double? hubLng, + DateTime? now, + }) { + if (stops.isEmpty) return const []; + + final clock = now ?? DateTime.now(); + final buckets = >>{}; + final windows = {}; + + for (final stop in stops) { + final explicitId = _firstNonEmpty(stop, [ + 'tripid', + 'tripId', + 'slotid', + 'slotId', + 'routeid', + 'routeId', + ]); + + final slotFrom = _parseTimestamp( + stop['slotstarttime'] ?? stop['slotStartTime'] ?? stop['slotfrom'], + ); + final slotTo = _parseTimestamp( + stop['slotendtime'] ?? stop['slotEndTime'] ?? stop['slotto'], + ); + + String key; + DateTime? from; + DateTime? to; + + if (explicitId != null) { + key = 'id:$explicitId'; + from = slotFrom; + to = slotTo; + } else if (slotFrom != null && slotTo != null) { + key = 'slot:${slotFrom.toIso8601String()}'; + from = slotFrom; + to = slotTo; + } else { + final due = _parseTimestamp( + stop['expected_pickup_time'] ?? + stop['expectedpickuptime'] ?? + 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()}'; + } else { + from = null; + to = null; + key = 'day:${clock.year}-${clock.month}-${clock.day}'; + } + } + + buckets.putIfAbsent(key, () => []).add(stop); + windows.putIfAbsent(key, () => (from, to)); + } + + final trips = [ + for (final entry in buckets.entries) + Trip.fromStops( + id: entry.key, + stops: entry.value, + slotStart: windows[entry.key]?.$1, + slotEnd: windows[entry.key]?.$2, + hubLat: hubLat, + hubLng: hubLng, + ), + ]; + + trips.sort((a, b) { + final as = a.slotStart; + final bs = b.slotStart; + if (as == null && bs == null) return 0; + if (as == null) return 1; // undated trips last + if (bs == null) return -1; + return as.compareTo(bs); + }); + + return _capToMaxTrips(trips, hubLat: hubLat, hubLng: hubLng); + } + + /// A rider's day is three trips. Anything past that is folded into the last. + /// + /// The bucketing above is time-based — a stop with no trip id lands in a + /// three-hour window — so a long day, or one stop booked well outside the + /// others, can produce a fourth or fifth bucket. That surfaced as a "Trip 4" + /// tab, which is not a thing the hub assigns. + /// + /// **Merged, never dropped.** Truncating the list would be the one-line fix + /// and it would silently hide real work: those stops are accepted bookings the + /// rider is expected to complete, and a stop he cannot see is a stop he cannot + /// deliver. The tail is therefore folded into the third trip, whose slot then + /// spans from its own start to the last stop's end, so the label stays honest + /// about what it now contains. + static List _capToMaxTrips( + List trips, { + double? hubLat, + double? hubLng, + }) { + if (trips.length <= maxTripsPerDay) return trips; + + final kept = trips.take(maxTripsPerDay - 1).toList(); + final tail = trips.skip(maxTripsPerDay - 1).toList(); + + final mergedStops = [for (final t in tail) ...t.stops]; + final starts = tail.map((t) => t.slotStart).whereType(); + final ends = tail.map((t) => t.slotEnd).whereType(); + + kept.add( + Trip.fromStops( + id: tail.first.id, + stops: mergedStops, + slotStart: starts.isEmpty + ? null + : starts.reduce((a, b) => a.isBefore(b) ? a : b), + slotEnd: ends.isEmpty + ? null + : ends.reduce((a, b) => a.isAfter(b) ? a : b), + hubLat: hubLat, + hubLng: hubLng, + ), + ); + return kept; + } + + /// 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. + static List> sortStops( + List> stops, + ) { + final indexed = <(int, Map)>[ + for (var i = 0; i < stops.length; i++) (i, stops[i]), + ]; + + 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]; + } + + // ── helpers ──────────────────────────────────────────────────────────── + + static ({double lat, double lng})? _coordsOf(Map stop) { + final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']); + final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']); + if (lat == 0 || lng == 0) return null; + return (lat: lat, lng: lng); + } + + static String? _firstNonEmpty(Map m, List keys) { + for (final k in keys) { + final v = m[k]; + final s = v?.toString().trim() ?? ''; + if (s.isNotEmpty && s != '0' && s != 'null') return s; + } + return null; + } + + static DateTime? _parseTimestamp(dynamic v) { + if (v == null) return null; + 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(); + final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), ''); + return double.tryParse(cleaned) ?? 0; + } +} + +/// ───────────────────────────────────────────────────────────────────────── +/// ADDRESS COMPRESSION +/// +/// A raw stop address is written for a postal system, not a rider: +/// +/// "5/2, North Avenue, Vadavalli, Coimbatore, Tamil Nadu, 641041" +/// +/// On a six-stop trip, "Coimbatore, Tamil Nadu, 641041" is repeated six times +/// and distinguishes nothing — every stop is in the same city, or the hub +/// would not have put them on one route. It is pure noise, and it is the noise +/// that pushes the useful part ("5/2, North Avenue") onto a second line and +/// then off the end of an ellipsis. +/// +/// So: find the trailing components EVERY stop on the trip shares, and drop +/// them. What survives is exactly the part that tells one stop from another. +/// The full address is never lost — it goes to the detail sheet and to the +/// navigation hand-off untouched. +/// +/// This is data-driven rather than a hardcoded list of cities and states: it +/// works for any city, any country, and degrades to a no-op on a one-stop trip +/// (where nothing is shared, so nothing is dropped). +/// ───────────────────────────────────────────────────────────────────────── + +/// Splits an address into trimmed, non-empty components. +List addressParts(String address) => + address.split(',').map((p) => p.trim()).where((p) => p.isNotEmpty).toList(); + +/// The trailing components shared by every address in [addresses]. +/// +/// Returns an empty list when fewer than two addresses are given, or when the +/// tails differ — there is then nothing redundant to remove. +List commonAddressTail(List addresses) { + final parts = [ + for (final a in addresses) + if (addressParts(a).isNotEmpty) addressParts(a), + ]; + if (parts.length < 2) return const []; + + final shortest = parts.map((p) => p.length).reduce((a, b) => a < b ? a : b); + final tail = []; + + for (var back = 1; back < shortest; back++) { + final candidate = parts.first[parts.first.length - back].toLowerCase(); + final shared = parts.every( + (p) => p[p.length - back].toLowerCase() == candidate, + ); + if (!shared) break; + tail.insert(0, parts.first[parts.first.length - back]); + } + return tail; +} + +/// [address] with [tail] removed. Always leaves at least one component, so a +/// stop can never render a blank address. +String stripAddressTail(String address, List tail) { + if (tail.isEmpty) return address.trim(); + final parts = addressParts(address); + var end = parts.length; + for (var i = tail.length - 1; i >= 0; i--) { + if (end <= 1) break; + if (parts[end - 1].toLowerCase() != tail[i].toLowerCase()) break; + end--; + } + return parts.sublist(0, end).join(', '); +} + +/// Where a single stop sits in the accept → work → done lifecycle. +/// +/// This is what lets one trip card serve both Home and Bookings. A trip does +/// not leave Home the moment it is accepted — the rider still wants to watch +/// it fill up — so every stop carries its own state and the card renders the +/// right control for it: Accept/Reject while pending, a progress tick once +/// it's moving, nothing at all once it's done. +enum StopState { + /// Awaiting the rider's decision. Shows Accept + Reject. + pending, + + /// Accepted, not yet started. Lives on the Bookings tab. + accepted, + + /// The rider is physically on this stop right now. + active, + + /// Picked up / delivered. + done, + + /// Skipped, awaiting a return visit. Not a failure. + skipped, + + /// The rider declined this stop. It stays visible, struck through, so he + /// can see he handled it rather than wondering where it went. + rejected, +} + +extension StopStateX on StopState { + /// Counts toward "trip complete". A rejected stop is resolved — the rider + /// has nothing left to do with it — so it must not hold the trip at 90% + /// forever. + bool get isResolved => this == StopState.done || this == StopState.rejected; + + /// Still needs a decision from the rider. + bool get needsDecision => this == StopState.pending; + + /// Committed to: he has taken it and owes the customer a visit. + bool get isCommitted => + this == StopState.accepted || + 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', + }; +} + +/// Derives a stop's state from its backend status plus the local +/// accepted/rejected stores. +/// +/// Local stores win for accept/reject because those writes are optimistic — +/// the rider's tap is recorded instantly and the network catches up. If the +/// server view won, a slow response would bounce a stop back to "pending" +/// under his thumb. +StopState stopStateOf( + Map stop, { + required Set acceptedIds, + required Set rejectedIds, +}) { + final id = (stop['orderid'] ?? '').toString(); + final raw = (stop['orderstatus'] ?? '').toString().trim().toLowerCase(); + + // 1. Irreversible server states win over everything. A parcel that has been + // collected or handed over cannot be un-decided by a local tap. + if (raw == 'picked' || + raw == 'picked up' || + raw == 'pickedup' || + raw == 'pickuped' || + raw == 'delivered' || + raw == 'cancelled' || + raw == 'canceled') { + return StopState.done; + } + + // 2. The rider is physically on the stop — also not a local decision. + 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. + // 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. + if (raw == 'rejected') return StopState.rejected; + if (raw == 'accepted') return StopState.accepted; + return StopState.pending; +} + +/// How a stop is progressing through the trip, for the progress rail. +enum StopProgress { + /// Finished — picked up / delivered. Rail turns green here. + done, + + /// The stop the rider is on right now. + current, + + /// Not started. + pending, + + /// Skipped and awaiting a return visit. + skipped, +} + +/// Formats a duration the way a rider plans: `2h 15m`, `45 min`. +String formatTripDuration(Duration d) { + if (d.inMinutes < 1) return '—'; + if (d.inMinutes < 60) return '${d.inMinutes} min'; + final minutes = d.inMinutes.remainder(60); + return minutes == 0 ? '${d.inHours}h' : '${d.inHours}h ${minutes}m'; +} + +/// Rounds a duration up to the nearest 5 minutes. +/// +/// An estimate printed as "37 min" claims a precision the arithmetic does not +/// have and invites the rider to treat it as a promise. "40 min" reads as +/// what it is. +Duration roundTripDuration(Duration d) { + if (d.inSeconds <= 0) return Duration.zero; + final minutes = (d.inMinutes / 5).ceil() * 5; + return Duration(minutes: math.max(5, minutes)); +} diff --git a/lib/views/Dashboard/home/trip_brief_strip.dart b/lib/views/Dashboard/home/trip_brief_strip.dart new file mode 100644 index 0000000..28f43c7 --- /dev/null +++ b/lib/views/Dashboard/home/trip_brief_strip.dart @@ -0,0 +1,590 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import 'package:miler/views/Dashboard/home/route_brief.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'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE TRIP BRIEF — one strip, opened only when the rider asks for it +/// +/// ── What this replaced, and what it cost ── +/// +/// The top of Home carried four separate surfaces before the first stop: a +/// `ShiftBanner` strip, a large white "CURRENT SHIFT" card holding a 25sp slot +/// window, a 66dp completion ring, a parcel chip and VIEW ROUTE, and then a row +/// of three shadowed stat cards for Duration, Distance and Payment. +/// +/// Measured at the 390×844 design size, that stack was **~351dp**: roughly +/// 49 for the banner, 168 for the shift card, 114 for the stat cards, plus the +/// gaps. On the same device the scrollable region under the app bar is about +/// 650dp, and the pinned trip tabs take ~56 of it. So **over half of the +/// scrollable screen was spent before the rider saw a single stop**, and on a +/// six-stop route he could see two of them. +/// +/// ── Why that was the wrong trade, not just a big one ── +/// +/// Every one of those figures is **start-of-shift briefing material**. The slot +/// window, the round-trip duration, the route distance, the parcel count and +/// the cash total are all fixed the moment the hub assigns the trip; none of +/// them changes while the rider works. They are read once, at the start, and +/// then they sit there for the rest of the shift charging full price. +/// +/// Only two things on that whole stack move during a shift: **how far through +/// he is**, and **how much of the shift is left**. Those are the two the rider +/// glances at repeatedly, and they were the two buried among six that he wasn't +/// looking at. So they stay on the face of the strip and everything else goes +/// one tap away — the same trade Uber Driver makes with its earnings header and +/// Google Maps makes with the collapsed navigation sheet: one live line, the +/// full breakdown on demand. +/// +/// ── Two things this also fixes ── +/// +/// • **The old card was mislabelled.** It printed the caption "CURRENT SHIFT" +/// above `trip.slotLabel`, which is the *trip's* window (09:00–13:00), not +/// the rider's shift (09:00–18:00). The real shift window was in the +/// `ShiftBanner` directly above it. Two different time ranges, stacked, one +/// of them wearing the other's name. Expanded, they are now adjacent rows +/// with their own labels, so the difference is finally legible. +/// +/// • **Progress was drawn twice.** A 66dp animated ring here, and "50% done" +/// on the pinned tab ~56dp below it. The ring is now 34dp and carries the +/// fraction as text beside it, because the tab already says the percentage. +/// +/// ── What is deliberately NOT hidden ── +/// +/// Collapsing must never hide a problem. Two things therefore stay on the +/// collapsed face: +/// +/// • **Time left**, coloured by [RouteBrief.isPaceAtRisk] and by whether the +/// window has closed. If the rider cannot finish in time, the strip says so +/// without being opened. +/// • **Cash to collect**, whenever there is any. Miler is the carrier, so that +/// money is the rider's personal liability until he reconciles it at the +/// hub — how much he is carrying is not reference material. +/// ───────────────────────────────────────────────────────────────────────── +class TripBriefStrip extends StatefulWidget { + final Trip trip; + final RouteBrief brief; + + /// Completion across the trip, 0–100. Supplied by the page because it is + /// computed from the accepted/rejected sets the page owns. + final int percent; + + /// Opens the whole route on one map. Hidden when the trip has no stops. + final VoidCallback? onViewRoute; + + const TripBriefStrip({ + super.key, + required this.trip, + required this.brief, + required this.percent, + this.onViewRoute, + }); + + @override + State createState() => _TripBriefStripState(); +} + +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; + + /// 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(); + + @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), + ), + ], + ), + // 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()], + ), + ), + ), + ); + } + + // ── 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; + + 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( + 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', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + _timeLeftLine(), + ], + ), + ), + if (cash > 0) ...[SizedBox(width: 8.w), _cashChip(cash)], + SizedBox(width: 2.w), + AnimatedRotation( + turns: _open ? 0.5 : 0, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + child: Icon( + Icons.keyboard_arrow_down_rounded, + size: 24.sp, + color: ColorConstants.secondaryText, + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// The one line on this widget that can carry bad news, so it is the one line + /// that is allowed to change colour. + Widget _timeLeftLine() { + final left = _brief.timeLeft; + + final String text; + final Color color; + + if (!_brief.shiftSet || left == null) { + // 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. + text = _trip.slotLabel; + color = ColorConstants.secondaryText; + } else if (left == Duration.zero) { + text = 'Shift ended'; + color = ColorConstants.error; + } else if (_brief.isPaceAtRisk) { + // Under six minutes a stop — faster than a pickup can physically be + // done. He needs to see this without opening anything. + text = '${formatDuration(left)} left · running tight'; + color = ColorConstants.warning; + } else { + text = '${formatDuration(left)} left in shift'; + color = ColorConstants.secondaryText; + } + + return Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w700, + color: color, + fontFamily: FontConstants.fontFamily, + ), + ); + } + + 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, + ), + ), + ); + } + + // ── The expanded brief ─────────────────────────────────────────────────── + // + // ── Why there are no cards in here ── + // + // Each figure used to sit in its own bordered, tinted box. That is four more + // surfaces inside a surface that is already inside a page — and it repeated + // the exact mistake the old permanent header made, just one tap further in: + // a box around a number adds no information, the number IS the information. + // Worse, a boxed figure has to be *aligned* somewhere inside its box, and + // left-aligning it parked every value in the top-left corner of an otherwise + // empty rectangle, so the band read as four mostly-blank cards rather than + // four facts. + // + // So: no boxes, and the figures are centred on their own column. One white + // surface, two bands of content, and the whitespace between them doing the + // grouping that borders were doing badly. + // + // 1. **The shift window**, as a full-width label→value row, and only when + // the hub has actually set one. The trip slot was a second row here until + // it turned out to be the same range the collapsed face already shows — + // see the note at that band. + // + // 2. **The four journey figures**, centred, evenly across one row. + // + // VIEW ROUTE is not here any more either; it is an action, and it now sits on + // the START · HUB row where the route actually begins. See `_hubRow`. + Widget _body() { + final cash = _trip.cashToCollect; + final duration = roundTripDuration(_trip.totalDuration); + + return Padding( + padding: EdgeInsets.fromLTRB(12.w, 0, 12.w, 14.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Divider(height: 1, color: ColorConstants.borderSubtle), + SizedBox(height: 10.h), + + // ── Band 1: the shift window ── + // + // The trip slot used to sit here as a second row. It was printing the + // same range the face prints two lines above it — [_timeLeftLine] + // falls back to `slotLabel` whenever the hub has not set a shift — so + // opening the strip showed the rider the identical window twice, once + // under "2 of 6 stops done" and once under "Trip slot". The slot is + // also on the trip tab right above the strip, which is where it + // belongs: it is what distinguishes one trip tab from another. + // + // So the row is gone, and this band now exists only when there is a + // shift window to state — a window the face reports as time *left* + // rather than as a range. + if (_brief.shiftSet) ...[ + _windowRow( + icon: Icons.event_available_rounded, + label: 'Shift', + value: _brief.shiftWindowLabel, + tint: ColorConstants.slateText, + ), + SizedBox(height: 14.h), + Divider(height: 1, color: ColorConstants.borderSubtle), + SizedBox(height: 14.h), + ], + + // ── Band 2: the journey, four centred figures on one row ── + // + // `IntrinsicHeight` so the four columns share a height and their + // captions land on one baseline even when a value scales down. + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _figure( + icon: Icons.schedule_rounded, + value: '≈${formatTripDuration(duration)}', + label: 'DURATION', + ), + ), + Expanded( + child: _figure( + icon: Icons.route_rounded, + value: RouteMetricsHelper.formatDistance(_trip.routeMeters), + label: 'DISTANCE', + ), + ), + Expanded( + child: _figure( + icon: Icons.inventory_2_rounded, + value: '${_trip.totalParcels}', + label: _trip.totalParcels == 1 ? 'PARCEL' : 'PARCELS', + ), + ), + Expanded( + child: cash > 0 + ? _figure( + icon: Icons.payments_rounded, + value: '₹${_money(cash)}', + label: 'PAYMENT', + money: true, + ) + // Holds its column so a cash-free trip keeps the row on + // four even columns instead of redistributing the space + // and shifting the other three figures sideways. + : _figure( + icon: Icons.money_off_rounded, + value: 'None', + label: 'PAYMENT', + muted: true, + ), + ), + ], + ), + ), + ], + ), + ); + } + + /// One time window: icon disc, label, and the range itself on the right. + Widget _windowRow({ + required IconData icon, + required String label, + required String value, + required Color tint, + }) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 5.h), + child: Row( + children: [ + Container( + width: 30.w, + height: 30.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: tint.withValues(alpha: 0.09), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Icon(icon, size: 16.sp, color: tint), + ), + SizedBox(width: 10.w), + Text( + label, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + const Spacer(), + Flexible( + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } + + /// One journey figure, centred on its column and standing on the page rather + /// than inside a box: icon, the number, a small-caps caption. + /// + /// The icon is bare, with no tinted disc behind it. At four across, a disc per + /// figure put four filled circles in a row that read as the loudest thing in + /// the brief while carrying none of its information — and the caption under + /// each value already says what the icon says. + Widget _figure({ + required IconData icon, + required String value, + required String label, + bool money = false, + bool muted = false, + }) { + // Only the money is coloured. The rest are facts about the journey, and + // tinting every category is the habit this screen has spent a long time + // unlearning. + final Color accent = money + ? ColorConstants.moneyGreen + : muted + ? ColorConstants.secondaryText + : ColorConstants.slateText; + + return Padding( + padding: EdgeInsets.symmetric(horizontal: 3.w), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + icon, + size: 17.sp, + color: money + ? ColorConstants.moneyGreen + : ColorConstants.secondaryText, + ), + SizedBox(height: 7.h), + // Fixed height with a scale-down inside it, so a long value shrinks + // rather than making one of the four columns taller than the rest. + SizedBox( + height: 21.h, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.center, + child: Text( + value, + style: TextStyle( + fontSize: 16.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + height: 1.05, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + SizedBox(height: 4.h), + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + label, + maxLines: 1, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 9.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.5, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } + + /// `1234.0` → `1,234`, `1234.5` → `1,234.5`. Mirrors the trim the stat card + /// used so the figure does not change shape as it moves into the strip. + static String _money(double v) { + final whole = v == v.roundToDouble(); + final s = whole ? v.toStringAsFixed(0) : v.toStringAsFixed(1); + final parts = s.split('.'); + final digits = parts.first; + final buf = StringBuffer(); + for (int i = 0; i < digits.length; i++) { + if (i > 0 && (digits.length - i) % 3 == 0) buf.write(','); + buf.write(digits[i]); + } + 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 new file mode 100644 index 0000000..ffebdd5 --- /dev/null +++ b/lib/views/Dashboard/home/trip_card.dart @@ -0,0 +1,1547 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import 'package:miler/views/Dashboard/home/route_brief.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/Font_constant.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// TRIP CARD — one slot's whole route, start to finish. +/// +/// The hub manager assigns a whole slot's route to one miler, so Home is +/// trip-shaped rather than a loose list of bookings. The card shows the route +/// exactly as it will be ridden: +/// +/// ⌂ START · HUB +/// ① PICKUP Priya Sharma ~5 min [Reject] [Accept] +/// ② DELIVERY Arun Venkatesh ~5 min ✓ Accepted +/// ③ BOTH Meena Krishnan ~10 min ✓ Completed +/// ⌂ RETURN · HUB +/// +/// Two things this card gets right that the old loose-booking list did not: +/// +/// • **The trip stays after it is accepted.** It does not vanish to Bookings. +/// The rider watches the same card fill up — a completion ring in the +/// header, stops turning green underneath — so Home stays a live picture of +/// his day instead of emptying out the moment he commits. +/// +/// • **Accept is per stop AND all at once.** A rider can take stops 1, 2 and +/// 4 and decline 3 — a hub can route him past an address he knows is shut. +/// So every pending stop carries its own Accept/Reject, and the bar at the +/// bottom does the remaining ones in one tap for the common case. +/// ───────────────────────────────────────────────────────────────────────── +class TripCard extends StatelessWidget { + final Trip trip; + + /// Ids the rider has accepted / rejected, so every stop renders its own + /// state. Optimistic — see [stopStateOf]. + final Set acceptedIds; + final Set rejectedIds; + + /// Order ids the rider has ticked. + /// + /// Selection is now the *only* way a stop is chosen — the per-row Accept and + /// Reject buttons, and the whole-trip accept button under the route, are all + /// gone. He ticks what he wants and takes it from the floating bar. + final Set selectedIds; + + /// Toggles one stop's tick. + final void Function(String orderId)? onToggleSelect; + + /// Accepts exactly the ticked stops. + final Future Function(List orderIds)? onAcceptSelected; + + /// Replaces the whole selection — used by the select-all row. + final void Function(List orderIds)? onSelectAll; + + /// Returns a rejected stop to undecided, so it can be ticked again. + /// + /// Deliberately not "accept it now": accepting is one deliberate press on the + /// floating bar, and a per-row control that quietly commits work would be the + /// per-stop Accept button coming back through the side door. + final Future Function(Map stop)? onUnrejectStop; + + /// Opens a single stop's detail sheet (map + full order details). + final void Function(Map stop)? onStopTap; + + /// Rings the customer for this stop. + final void Function(Map stop)? onCallStop; + + /// Opens the whole route on a map. Null hides the action. + final VoidCallback? onViewRoute; + + /// 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 + /// [buildSummary] needs it: the shift window and the trip's own slot are two + /// different time ranges and the brief is where they are finally shown side + /// by side. Null renders the summary without the shift lines. + final RouteBrief? brief; + + /// Order ids with an action in flight, so a stop cannot be re-ticked while + /// its acceptance is on the wire. + final Set busyStopIds; + + const TripCard({ + super.key, + required this.trip, + this.acceptedIds = const {}, + this.rejectedIds = const {}, + this.brief, + this.selectedIds = const {}, + this.onToggleSelect, + this.onAcceptSelected, + this.onSelectAll, + this.onUnrejectStop, + this.onStopTap, + this.onCallStop, + this.onViewRoute, + this.busyStopIds = const {}, + }); + + StopState _stateOf(Map stop) => + stopStateOf(stop, acceptedIds: acceptedIds, rejectedIds: rejectedIds); + + String _idOf(Map stop) => (stop['orderid'] ?? '').toString(); + + @override + Widget build(BuildContext context) { + // No frame around the trip. + // + // The card used to be one bordered, shadowed white box holding a second + // bordered box per stop — a frame inside a frame inside a slate page. The + // outer one was the weaker of the two: it grouped things the tabs above + // had already grouped, and it charged 16px of page margin plus 16px of its + // own padding plus a border for the privilege, on the narrowest axis the + // rider has. So it is gone, and the stops below take the boxes instead — + // they are what he actually picks between. Summary and footer now sit + // directly on the page, and the seam dividers went with the frame. + return Padding( + // ── The horizontal scale for this whole screen ── + // + // Every inset on Home is one of two values, and both are 4pt steps off + // this base: + // + // **28 — the page inset.** The shift card, the three stat cards, the + // pinned tab strip and the accept bar. These are full-width page + // elements and they all begin and end here. + // + // **32 — the route inset.** The rail's numbered node and the stop + // card's right edge. One step further in, because the rail-plus-card is + // an indented list item *under* the summary rather than another card in + // the same column, and it should read that way. + // + // They arrived at 16, 18, 20 and 28 respectively — patched one complaint + // at a time — which is what a gutter looks like when nobody writes the + // scale down. `card_density_test.dart` now holds both values. + // + // 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), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [buildSummary(), buildStops(), buildActionBar()], + ), + ); + } + + // ═══════════════════════════════════════════════════════════════════════ + // THE CARD IN THREE PIECES + // + // Home does not use [build]; it places these three sections as separate + // slivers so that the Trip 1 / 2 / 3 bar can be a *pinned* sliver sitting + // between the summary and the stops. A pinned header has to be a sliver in + // its own right, which is impossible from inside one `SliverToBoxAdapter` + // holding the whole card. + // + // They stay methods on this widget rather than three separate widgets because + // the stop list alone needs nine of this class's fields — splitting would mean + // maintaining that list twice. All three are pure and take no `BuildContext`, + // so calling them from the page is safe. + // + // [build] still composes them in order, so any caller wanting the whole card + // as one widget — and the widget tests — keep working unchanged. + // ═══════════════════════════════════════════════════════════════════════ + + /// The collapsed trip brief: progress, time left, cash — and everything else + /// one tap away. See [TripBriefStrip] for what this used to be and why the + /// four surfaces it replaced were the wrong price for the information. + Widget buildSummary() => TripBriefStrip( + trip: trip, + brief: brief ?? RouteBrief.empty, + percent: trip.completionPercent( + acceptedIds: acceptedIds, + rejectedIds: rejectedIds, + ), + onViewRoute: onViewRoute, + ); + + /// HUB → every stop in order → HUB. + Widget buildStops() => _sequence(); + + /// Accept-remaining / selection bar, or the finished confirmation. + Widget buildActionBar() => _footerBar( + trip.pendingOrderIds(acceptedIds: acceptedIds, rejectedIds: rejectedIds), + ); + + // ── HUB → stops → HUB ────────────────────────────────────────────────── + Widget _sequence() { + // Computed once for the whole trip, not per stop — it is a comparison + // across all of them. + final addressTail = trip.sharedAddressTail; + + return Padding( + // ── The stop rows sit inside the summary cards, symmetrically ── + // + // The whole route block is inset 8 further than the shift and stat cards + // above it: the rail's numbered node starts at 28 and the stop card's + // right edge lands at 28, against the summary's 20. + // + // Both sides moved together on purpose. Insetting only the right made the + // card's own margins even but left the rail closer to the border than + // anything else on the screen, so the row still read as pushed against + // the edge — just from the other side. A list indents as a block or it + // does not indent at all. + // + // And the block genuinely is one: a rail plus its card is an indented list + // item under the summary, not another card in the same column, so it is + // correct that it does not share a page edge with the cards above. + padding: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 8.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Select-all leads the list it selects ── + // + // This sat at the *foot* of the route, under the last hub row. On a + // six-stop trip that is a full screen of scrolling away from the + // first stop, and it is the control a rider reaches for **before** + // reading the list, not after: "take the lot" is the common case, and + // he was made to scroll past everything he had just decided to take + // in order to say so. + // + // At the head of the list it is the first thing under the trip tabs, + // it reads as a heading for the rows beneath it — which is what a + // select-all is — and the running count sits where his eye already is + // when he ticks a row. + _selectAllRow(), + _hubRow(isStart: true), + // ── One screen, one question ── + // + // Home lists the stops that still need something *from him here*: + // + // • **pending** — undecided, waiting to be ticked and accepted. + // • **rejected** — declined, kept visible so he can see he handled + // it rather than wondering where it went, and can take it back. + // + // Everything else has moved to the tab where it is acted on: accepted + // and in-progress stops to Bookings, finished ones to Activity. Both + // used to sit here as rows the rider could do nothing with, and there + // is no bound on how many of those a shift produces — six stops in, + // he was scrolling past six dead cards to reach one live one, so the + // screen got harder to use in exact proportion to how much work he + // had done. + // + // **Skipped** stops went the same way, to Activity. A skip is a + // record of something that happened at a door — like a completed + // stop, and unlike a pending one — and Home is the screen for the one + // question "what am I taking?". Keeping skips here meant the accept + // list grew a permanent tail of stops that were not on offer, and the + // rider had to read past his own morning to find the afternoon's + // work. Activity carries them with the reason he gave and the same + // Resume control. + // + // They are filtered at the *row*, not removed from [trip]: the + // brief's ring and its "2 of 6 stops done" are computed from the + // trip, and a trip that shed its accepted and finished stops would + // 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), + // 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), + ], + ), + ); + } + + /// The band the hub node and its label share. + /// + /// Tall enough for the tallest thing on the row, which is the MAP action, not + /// the node — see [_hubRow]. Both sides are centred in this one height, so + /// they line up by construction at any text scale instead of by a nudge that + /// only holds at one. + double get _hubBand => 36.h; + + Widget _hubRow({required bool isStart}) { + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + // The node, centred in the shared band rather than sitting at the + // top of it. + SizedBox( + height: _hubBand, + child: Center( + child: Container( + width: 22.w, + height: 22.w, + decoration: BoxDecoration( + color: _hubColor.withValues(alpha: 0.14), + shape: BoxShape.circle, + border: Border.all(color: _hubColor, width: 1.5), + ), + child: Icon( + Icons.warehouse_rounded, + size: 12.sp, + color: _hubColor, + ), + ), + ), + ), + if (isStart) + Expanded( + child: Container( + width: 2, + margin: EdgeInsets.symmetric(vertical: 3.h), + color: ColorConstants.borderSubtle, + ), + ), + ], + ), + SizedBox(width: 8.w), + Expanded( + // ── The label sits level with the node, not near it ── + // + // This was a 1dp top padding, which is a guess at where a 11.5sp + // line lands inside a 22dp circle — wrong at every text scale but + // the one it was eyeballed at, so the hub icon read as floating + // above its own label. + // + // It was then boxed to the *node's* height, 22, which fixed the + // alignment and broke the row: the MAP action beside the label is a + // tap target with its own padding and a chevron, comfortably taller + // than 22, so it was cropped through the middle. Half a control is + // worse than a misaligned icon. + // + // Both sides are now centred in one band sized for the tallest + // thing on the row ([_hubBand]). Nothing is clipped and the two + // centres coincide by construction. The bottom padding stays + // outside the band so the connector below still gets its gap. + child: Padding( + padding: EdgeInsets.only(bottom: isStart ? 10.h : 0), + child: SizedBox( + height: _hubBand, + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + // `spaceBetween` with a [Flexible] label rather than a + // `Spacer`: a Spacer is flex-1, so it would have split the free + // space evenly with a flexible label and squeezed the text for + // no reason. This way the label takes its natural width, the + // action takes its own, and only a genuinely narrow screen + // makes the label give ground. + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + isStart ? 'START · HUB' : 'RETURN · HUB', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.7, + color: _hubColor, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + + // The long caption ("Leave the hub with the delivery + // parcels") was read once and then never again — it is the + // same on every trip, every day. The label alone carries it. + + // ── Why the map lives on this row ── + // + // It was buried at the bottom of the trip brief, which is the + // wrong place twice over: it is an *action* among a list of + // read-only figures, and it was two taps deep behind a strip + // the rider has no other reason to open. + // + // The route starts here. "Before you leave the hub, here is + // the whole run on a map" is the one moment the map answers a + // question the rider is actually asking, and this row is + // where his eye already is when he starts. It also had a slot + // going spare — the caption that used to fill it is gone. + if (isStart && onViewRoute != null) + _ViewRouteAction(onTap: onViewRoute!), + ], + ), + ), + ), + ), + ], + ), + ); + } + + /// "Select all · N of M selected", at the head of the route. + /// + /// Rendered only when there is more than one undecided stop — with one stop + /// its own tick already is the select-all — and only when the page supplies a + /// selection handler. + /// + /// It is a tinted strip rather than a bare row: it sits between the trip tabs + /// and the first hub row, both of which are strong horizontal marks, and an + /// unbacked tick out here read as a stray control belonging to neither. + Widget _selectAllRow() { + final pendingIds = trip.pendingOrderIds( + acceptedIds: acceptedIds, + rejectedIds: rejectedIds, + ); + if (onToggleSelect == null || pendingIds.length < 2) { + return const SizedBox.shrink(); + } + + final picked = pendingIds.where(selectedIds.contains).toList(); + final all = picked.length == pendingIds.length; + final some = picked.isNotEmpty && !all; + + return Padding( + padding: EdgeInsets.only(bottom: 8.h), + child: Material( + color: some || all + ? ColorConstants.primary.withValues(alpha: 0.06) + : ColorConstants.neutralLight, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: InkWell( + // The whole strip toggles, not just the 28pt box — this is the one + // control on the screen the rider reaches for without looking. + onTap: () => onSelectAll?.call(all ? const [] : pendingIds), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), + child: Row( + children: [ + _SelectBox( + selected: all, + partial: some, + accent: ColorConstants.primary, + label: all + ? 'Clear the selection' + : 'Select every pending stop', + onTap: () => onSelectAll?.call(all ? const [] : pendingIds), + ), + SizedBox(width: 5.w), + Expanded( + child: Text( + picked.isEmpty + ? 'Select all ${pendingIds.length} stops' + : '${picked.length} of ${pendingIds.length} selected', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + color: picked.isEmpty + ? ColorConstants.secondaryText + : ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + if (picked.isNotEmpty) + Padding( + padding: EdgeInsets.only(right: 8.w), + child: Text( + all ? 'CLEAR' : 'ALL', + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + color: ColorConstants.primary, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// 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, + ), + ), + ], + ), + ); + } + + /// 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. + 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, + ), + 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) { + 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]. + 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), + + // ── 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), + + // 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), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } + + // `_tag` is gone — it built the tinted Deliver / Collect / ₹ chips. See + // [_MetaLine], which says the same three things on one uncoloured line. + + // ── Footer: accept remaining, or go work the trip ────────────────────── + Widget _footerBar(List pendingIds) { + final finished = trip.isFinished( + acceptedIds: acceptedIds, + rejectedIds: rejectedIds, + ); + + if (finished) { + return Padding( + padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 10.h), + child: Container( + height: ButtonSizes.primary, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.acceptGreen.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(ButtonSizes.radius), + border: Border.all( + color: ColorConstants.acceptGreen.withValues(alpha: 0.4), + width: 1.5, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.verified_rounded, + size: 20.sp, + color: ColorConstants.acceptGreen, + ), + SizedBox(width: 8.w), + // Flexible for the same reason as [_LockedNote]'s: this sentence + // is wider than the bar it sits in once the type scales up, and + // an unflexed Text in a centred Row overflows rather than + // shrinking. + Flexible( + child: Text( + 'Trip complete · head back to the hub', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w800, + color: ColorConstants.acceptGreen, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ); + } + + // Nothing left to decide. The way on to the work is the accepted-count pill + // floating over the page — one route to Bookings, always in the same place, + // rather than a button that appears at the foot of a trip only once that + // trip happens to be fully decided. + if (pendingIds.isEmpty) return const SizedBox.shrink(); + + // Only ticks on stops that are still this trip's and still undecided. + final picked = pendingIds.where((id) => selectedIds.contains(id)).toList(); + + // ── One accept, one place ── + // + // An "Accept all N stops" button used to sit here, under the last hub row. + // It was a second bulk action over a *different* set of stops from the + // floating bar's — and the difference between the two sets was exactly the + // stops the rider had just decided he did not want. Now there is one accept + // in the whole screen, on the floating bar, and it always means "the ones I + // ticked". Taking a whole trip is the select-all tick at the head of the + // list followed by that same button. + // + // What is left down here is one line telling him where the action went. The + // 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, + ), + ), + ); + } +} + +/// 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 +/// saying what the map pin beside it and the destination screen both already +/// say. "MAP" is the noun the rider uses for the thing he is opening. +/// +/// A text action rather than a button: it sits on a label row inside the route +/// list, and a filled control there would out-rank the Accept bar that carries +/// the trip's actual decision. Text plus a chevron is the standard way to say +/// "there is more of this elsewhere" without claiming to be the main thing on +/// the screen. +/// +/// The visible text is tight against the row's right edge while the tap target +/// extends past it in every direction, so it clears the 44dp floor without the +/// label appearing to float away from the margin. +class _ViewRouteAction extends StatelessWidget { + final VoidCallback onTap; + const _ViewRouteAction({required this.onTap}); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'View the whole route on a map', + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 7.h), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.map_rounded, + size: 14.sp, + color: ColorConstants.primary, + ), + SizedBox(width: 5.w), + Text( + 'MAP', + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + color: ColorConstants.primary, + fontFamily: FontConstants.fontFamily, + ), + ), + Icon( + Icons.chevron_right_rounded, + size: 16.sp, + color: ColorConstants.primary, + ), + ], + ), + ), + ), + ); + } +} + +/// A trailing control slot on the meta row. +/// +/// 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. +/// +/// 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 ── +/// +/// 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. +/// +/// 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. +/// +/// 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}); + + ({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, + ), + }; + + @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, + ), + ), + ), + ], + ); + } +} + +/// 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 new file mode 100644 index 0000000..0c6d0e3 --- /dev/null +++ b/lib/views/Dashboard/home/trip_empty_state.dart @@ -0,0 +1,264 @@ +import 'package:flutter/material.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'; + +/// ───────────────────────────────────────────────────────────────────────── +/// TRIP NOT ASSIGNED YET +/// +/// The state a trip tab sits in before the hub fills it. Deliberately not the +/// generic "no bookings" screen: the rider's question on Trip 3 at 10am is +/// "will I get a third trip?", and the honest answer is "not yet" — not "you +/// have no work", which is false while Trip 1 is running. +/// +/// **No card.** This used to be a bordered white box floating on the page, +/// which read as a piece of *content* the hub had sent — a card that had +/// arrived with nothing in it. An empty slot is the absence of content, so it +/// is drawn as the page itself: no border, no fill, no shadow, the same +/// boxless treatment every other empty screen in the app uses through +/// [MilerEmptyState]. +/// +/// **It breathes.** Waiting is the whole subject of the screen, so the halo +/// swells and settles on a slow four-second cycle and the chip's dot pulses +/// with it. A still screen saying "waiting" is a claim the interface does not +/// back up; a rider glancing down needs to see that the app is still watching +/// for his trip and has not simply stopped. Off duty is not waiting — nothing +/// is coming until he goes on — so there the motion stops and the colour +/// drains to grey. +/// ───────────────────────────────────────────────────────────────────────── +class TripEmptyState extends StatefulWidget { + /// `Trip 2` — the tab this slot belongs to, named the same way the tabs, + /// the hub and his supervisor name it. + final String tripLabel; + + /// Off duty. A different sentence, a different colour, and the one action + /// that changes it. + final bool offline; + + /// Opens the duty confirmation. Only used when [offline]. + final VoidCallback? onGoOnDuty; + + const TripEmptyState({ + super.key, + required this.tripLabel, + required this.offline, + 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. + final Color accent = offline + ? ColorConstants.secondaryText + : ColorConstants.primary; + + // The button is the exception, and deliberately: a grey "Go on duty" on a + // grey screen reads as a disabled control, which is the opposite of what it + // is — the one live thing on an off-duty screen. Green is what going on + // duty is coloured everywhere else in the app. + final Color cta = ColorConstants.acceptGreen; + + return Padding( + // Generous top air. With the card gone there is no edge to separate this + // from the pinned tab bar, so the space does that job — and an empty + // screen is the one place in the app that can afford it. + // + // Vertical only: [MilerEmptyState] already insets its copy by 40 a side, + // 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', + 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.', + 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), + ), + ); + } +} + +/// The icon on two soft rings that swell and settle together. +class _BreathingHalo extends StatelessWidget { + final Animation breath; + final Color accent; + final IconData icon; + final bool animate; + + const _BreathingHalo({ + required this.breath, + required this.accent, + required this.icon, + required this.animate, + }); + + @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, + 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, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/Dashboard/home/trip_progress_rail.dart b/lib/views/Dashboard/home/trip_progress_rail.dart new file mode 100644 index 0000000..871b2b1 --- /dev/null +++ b/lib/views/Dashboard/home/trip_progress_rail.dart @@ -0,0 +1,491 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.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'; + +/// ───────────────────────────────────────────────────────────────────────── +/// TRIP PROGRESS — how far through the route the rider is. +/// +/// Shown at the top of Bookings, above the stop list: +/// +/// 10:00 AM – 1:00 PM 4 of 18 done +/// ████████▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +/// On stop 5 of 18 · 2 skipped · 2h 10m left, then hub +/// +/// **No box.** This was a white card — 1.5px border, 16px radius, a drop +/// shadow, 12px of padding on all four sides and a 12px top margin — sitting +/// directly above a list of white bordered stop cards. It cost roughly 40px of +/// height for chrome alone, on the first screen of the page, and the border it +/// spent that height on was the same border the cards below already use, so it +/// read as a stop card that could not be tapped. +/// +/// A progress bar does not need a container to be legible: it is already a +/// shape with a hard edge. So the box, its shadow, its padding and the icon on +/// the title row are gone, and what is left sits straight on the page in the +/// same 16px gutter as the cards. Same three facts, about half the height, and +/// the first real stop is now visible without scrolling. +/// +/// ── Numbered nodes, not a proportional bar ── +/// +/// A five-stop route draws 1 2 3 4 5, and two stops done means two of those +/// numbers are filled green. The bar that was here before was honest about the +/// *fraction* — 40% of its width green — but a fraction is not the unit the +/// rider works in. He is on stop 3; the list below numbers its cards 1…5; the +/// map pins are numbered. The summary now speaks the same language as +/// everything it summarises, and tapping a number is a way back to that stop. +/// +/// The scooter rides the rail: a `two_wheeler` glyph sits above the node the +/// rider is working, so where-am-I is one glance rather than a count — and when +/// a stop is completed it **drives forward to the next one** while the node it +/// left turns green. Progress on this page is something the rider watches +/// happen, not something he re-reads. Colour does the rest: +/// +/// • **Green** for completed. Green is the app's only "done" signal. +/// • **Amber** for skipped: a skipped stop is neither done nor waiting, and it +/// has to stay visible as unfinished business. +/// • **Maroon** for the stop he is on, under the scooter. +/// • **Grey** for everything still ahead. +/// +/// ── The reason numbers were dropped once before, and what changed ── +/// +/// This widget used to draw a node per stop inside a fixed-width row, so route +/// length — set by the hub, bounded by nothing here — squeezed the nodes into +/// each other until the digits were unreadable and the hub markers collided. It +/// got worse exactly as the rider's day got harder, which is why it became a +/// bar. +/// +/// The nodes are back because that failure was a layout bug, not a problem with +/// numbering: the row was made to fit an unbounded count into a fixed width. +/// Now the nodes have a **fixed size and the row scrolls** when they no longer +/// fit, and it auto-scrolls to keep the scooter in view. A 5-stop route lays +/// out evenly across the full width; a 40-stop route draws the same 34pt nodes +/// and lets the rider push the rest into view. The widget's height is constant +/// either way — see `trip_progress_test.dart`, which is what stops this from +/// regressing into a crush again. +/// ───────────────────────────────────────────────────────────────────────── +class TripProgressRail extends StatefulWidget { + /// Progress state per stop, in the admin's fixed order. + final List progress; + + /// Slot window label, e.g. `10:00 AM – 1:00 PM`. + final String? slotLabel; + + /// Remaining time estimate for the unfinished part of the trip. + final Duration? remaining; + + /// Tapping a node scrolls to / opens that stop. + final void Function(int index)? onStopTap; + + const TripProgressRail({ + super.key, + required this.progress, + this.slotLabel, + this.remaining, + this.onStopTap, + }); + + @override + State createState() => _TripProgressRailState(); +} + +class _TripProgressRailState extends State { + /// Only attached when the rail actually overflows; a short route lays out + /// with no scroll view at all. + final ScrollController _scroll = ScrollController(); + + /// Node footprint, and the tightest the gap between two nodes is allowed to + /// get. 34 rather than the 26 this started at: the numbers are the rail's + /// content, and at 26 a two-digit stop filled its circle wall to wall. + static const double _node = 34; + static const double _minGap = 22; + + /// The scooter's box, and the air between it and the nodes it rides over. + static const double _scooter = 24; + static const double _lift = 3; + + /// Constant, by construction — nodes never shrink and the scooter always + /// rides above them, so route length changes the rail's *width* only. + double get _railHeight => _scooter.w + _lift.h + _node.w; + + /// How long the scooter takes to travel one stop. Long enough to read as + /// movement — the point is that the rider *sees* his progress advance when a + /// stop is completed, rather than finding the marker already somewhere else. + static const Duration _ride = Duration(milliseconds: 480); + + List get progress => widget.progress; + String? get slotLabel => widget.slotLabel; + Duration? get remaining => widget.remaining; + + @override + void initState() { + super.initState(); + _scheduleReveal(); + } + + @override + void didUpdateWidget(covariant TripProgressRail old) { + super.didUpdateWidget(old); + // The rider finishing a stop moves the scooter, and on a long route the + // node it moves to may be off the right edge. + if (old.progress.length != progress.length || + _currentPosition != _positionIn(old.progress)) { + _scheduleReveal(); + } + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + /// Centres the scooter's node after layout, when there is anything to scroll. + void _scheduleReveal() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scroll.hasClients) return; + final at = _currentPosition; + if (at == null) return; + + final double viewport = _scroll.position.viewportDimension; + final double target = + ((at - 1) * (_node.w + _minGap.w)) - (viewport / 2) + (_node.w / 2); + final double clamped = target.clamp( + 0.0, + _scroll.position.maxScrollExtent, + ); + + if ((clamped - _scroll.offset).abs() < 1) return; + _scroll.animateTo( + clamped, + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + ); + }); + } + + int get _doneCount => progress.where((p) => p == StopProgress.done).length; + + int get _skippedCount => + progress.where((p) => p == StopProgress.skipped).length; + + /// 1-based position of the stop the rider is on, or the first one still + /// waiting. Null once nothing is left. + int? get _currentPosition => _positionIn(progress); + + int? _positionIn(List states) { + final active = states.indexOf(StopProgress.current); + if (active >= 0) return active + 1; + final next = states.indexOf(StopProgress.pending); + return next >= 0 ? next + 1 : null; + } + + /// Spacing between two node centres. + /// + /// A short route spreads its nodes evenly across the whole width rather than + /// huddling on the left; once even spacing would fall below [_minGap] the gap + /// locks at that floor and the rail starts scrolling instead. Nodes never + /// shrink — that was the failure that killed the first version of this rail. + double _gapFor(double available, int total) { + if (total < 2) return 0; + final double even = (available - (total * _node.w)) / (total - 1); + return even >= _minGap.w ? even : _minGap.w; + } + + /// The rail: one numbered node per stop, joined by connectors, with the + /// scooter riding above the stop the rider is working. + /// + /// ── Why the scooter is an overlay and not just the current node's glyph ── + /// + /// It used to *be* the node — the current circle drew a scooter instead of + /// its digit. That cost the stop its number, and worse, finishing a stop + /// simply repainted two circles: the marker was already at the next stop + /// before the rider's eye got there, so nothing appeared to happen. As a + /// separate layer it can be *positioned*, which means it can be animated, so + /// completing a stop visibly drives the scooter forward one node ([_ride]) + /// and every stop keeps its number underneath. + Widget _rail() { + final total = progress.length; + + 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); + // +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 = []; + for (var i = 0; i < total; i++) { + if (i > 0) { + // A connector is coloured by the stop *behind* it, so the green + // runs continuously from the start of the route up to the scooter + // rather than restarting at each node. + children.add(_connector(progress[i - 1], width: gap)); + } + children.add(_dot(i)); + } + + final int? at = _currentPosition; + + final Widget layers = SizedBox( + width: fits ? available : content, + height: _railHeight, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: 0, + bottom: 0, + child: Row(mainAxisSize: MainAxisSize.min, children: children), + ), + if (at != null) + AnimatedPositioned( + duration: _ride, + curve: Curves.easeOutCubic, + top: 0, + // 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), + child: _scooterGlyph(), + ), + ], + ), + ); + + if (fits) return layers; + + return SizedBox( + height: _railHeight, + child: SingleChildScrollView( + controller: _scroll, + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + child: layers, + ), + ); + }, + ); + } + + /// The rider, in the app's own maroon. No disc behind it: the rail sits bare + /// on the page and a filled badge here would outweigh the node it points at. + Widget _scooterGlyph() { + return SizedBox( + width: _scooter.w, + height: _scooter.w, + child: Icon( + Icons.two_wheeler_rounded, + size: 20.sp, + color: ColorConstants.primary, + ), + ); + } + + /// One stop, as the number the rest of the page calls it by. + /// + /// Every node keeps its digit, including the one the rider is on — the + /// scooter rides above it rather than replacing it. The fill animates on + /// [_ride] so a stop turning green is a change the rider can see happen, + /// in step with the scooter leaving it. + Widget _dot(int index) { + final state = progress[index]; + + final Color fill; + final Color ink; + switch (state) { + case StopProgress.done: + fill = ColorConstants.acceptGreen; + ink = Colors.white; + case StopProgress.skipped: + fill = ColorConstants.warning; + ink = Colors.white; + case StopProgress.current: + fill = ColorConstants.primary; + ink = Colors.white; + case StopProgress.pending: + // Filled grey rather than an outlined circle: the rail sits bare on the + // page above bordered stop cards, and a ring per stop would put a row + // of small competing edges directly above them. + fill = ColorConstants.neutralLight; + ink = ColorConstants.secondaryText; + } + + final Widget circle = AnimatedContainer( + duration: _ride, + curve: Curves.easeOutCubic, + width: _node.w, + height: _node.w, + alignment: Alignment.center, + decoration: BoxDecoration(color: fill, shape: BoxShape.circle), + child: Text( + '${index + 1}', + style: TextStyle( + // 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, + letterSpacing: -0.3, + height: 1, + color: ink, + fontFamily: FontConstants.fontFamily, + ), + ), + ); + + return Semantics( + button: widget.onStopTap != null, + label: 'Stop ${index + 1}, ${_stateWord(state)}', + child: GestureDetector( + onTap: widget.onStopTap == null ? null : () => widget.onStopTap!(index), + behavior: HitTestBehavior.opaque, + child: circle, + ), + ); + } + + static String _stateWord(StopProgress state) => switch (state) { + StopProgress.done => 'done', + StopProgress.skipped => 'skipped', + StopProgress.current => 'you are here', + StopProgress.pending => 'not started', + }; + + /// The line between two nodes. Green behind a finished stop, amber behind a + /// skipped one, grey ahead — so the rail reads as a route travelled, not as a + /// set of unrelated badges. + Widget _connector(StopProgress behind, {required double width}) { + final Color color = switch (behind) { + StopProgress.done => ColorConstants.acceptGreen, + StopProgress.skipped => ColorConstants.warning, + _ => ColorConstants.borderStrong, + }; + + return AnimatedContainer( + duration: _ride, + curve: Curves.easeOutCubic, + width: width, + height: 4.h, + color: color, + ); + } + + @override + Widget build(BuildContext context) { + if (progress.isEmpty) return const SizedBox.shrink(); + + final total = progress.length; + final done = _doneCount; + final allDone = done == total; + + return Padding( + // Same 16px gutter as the stop cards below, so the bar lines up with + // their edges instead of being inset by a border's worth again. + padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 10.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Headline: slot + progress count ── + // + // The count lost its pill. A bordered chip around two words, directly + // above a bordered bar, was two edges saying one thing; colour and + // weight carry "done" here exactly as they do everywhere else in the + // app, and green already means finished. + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text( + slotLabel ?? 'Your route', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 8.w), + Text( + allDone ? 'All stops done' : '$done of $total done', + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + color: allDone + ? ColorConstants.acceptGreen + : ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + + SizedBox(height: 9.h), + + // ── The rail ── + // + // One numbered node per stop, the finished ones green, the scooter on + // the one he is working. Fixed node size with a horizontal scroll + // behind it, so a 40-stop route is longer, never smaller. + _rail(), + + SizedBox(height: 8.h), + + // ── Foot: where he is, and what is left, on one line ── + // + // The rail used to spend this line on START·Hub / RETURN·Hub labels + // at either end. Both are constant on every route, every day — the + // trip always leaves the hub and always comes back — so they were + // paying for width with no information. What changes, and what he + // actually wants, is which stop he is on and how long is left. + // + // Both halves used to sit at opposite ends of a Row, which left a gap + // in the middle wide enough to read as two separate facts. They are + // one sentence about one trip, so they are now one string, and the + // whole line ellipsises as a unit if the route label is long. + Text( + allDone + ? 'Head back to the hub' + : _footStatus(total: total, done: done), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w700, + color: allDone + ? ColorConstants.acceptGreen + : ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } + + String _footStatus({required int total, required int done}) { + final at = _currentPosition; + final skipped = _skippedCount; + final left = remaining; + final parts = [ + 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', + ]; + return parts.join(' · '); + } +} + +/// 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 new file mode 100644 index 0000000..1f20adf --- /dev/null +++ b/lib/views/Dashboard/home/trip_tabs.dart @@ -0,0 +1,707 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.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'; +import 'package:miler/views/helpers/constants/design_constants.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// TRIP TABS — Trip 1 · Trip 2 · Trip 3 +/// +/// A rider is assigned at most three trips a day, so three tabs fit across a +/// phone and every trip is one tap away. Tabs beat a dropdown here for a +/// reason: with a dropdown the rider cannot see how many trips he has or how +/// far along each one is without opening it, and "how much is left today" is +/// the question he asks most. +/// +/// Each tab carries its own completion percentage, so the whole day reads at a +/// glance: `Trip 1 · 100% Trip 2 · 40% Trip 3 · 0%` +/// +/// **Overflow.** Three is the norm, not a guarantee — a hub can assign more. +/// Past three the bar keeps the first two tabs and folds the rest into a +/// "More" menu, rather than shrinking every tab until the labels are +/// unreadable. The dropdown is the exception path, exactly as asked. +/// ───────────────────────────────────────────────────────────────────────── +class TripTabs extends StatefulWidget { + final List trips; + final int selectedIndex; + final ValueChanged onSelect; + + /// Optimistic accept/reject sets, for the per-tab completion figure. + final Set acceptedIds; + final Set rejectedIds; + + /// Tabs shown inline before the rest collapse into the overflow menu. + /// + /// Also the number of tabs ALWAYS rendered: a rider's day is three trips, so + /// Trip 2 and Trip 3 are shown even before the hub has assigned them. A tab + /// that appears and disappears through the morning makes the screen feel + /// unstable; an empty Trip 3 that says "nothing assigned yet" answers the + /// question he actually has. + static const int maxInlineTabs = 3; + + /// The travelling selection marker at the foot of the pill. + static const Key markerKey = ValueKey('trip_tab_marker'); + + const TripTabs({ + super.key, + required this.trips, + required this.selectedIndex, + required this.onSelect, + this.acceptedIds = const {}, + this.rejectedIds = const {}, + }); + + @override + State createState() => _TripTabsState(); +} + +class _TripTabsState extends State + with SingleTickerProviderStateMixin { + /// Carries the pill from the tab it was on to the tab just chosen. + /// + /// Same mechanism as the bottom nav bar, and for the same reason. The pill + /// used to belong to each tab — an `AnimatedContainer` per segment whose fill + /// went from transparent to white — so moving from Trip 3 to Trip 1 faded one + /// pill out on the right and a *different* one in on the left. Nothing ever + /// occupied the space between, so there was no object to travel across Trip 2 + /// with, and the marker appeared to teleport. + /// + /// There is one pill now, owned by the control, and this is its position in + /// *slot* units — 1.5 is genuinely halfway between the second and third + /// segment. It also drives the tint on every label, so a tab warms up as the + /// pill passes over it instead of switching the instant it is tapped while the + /// pill is still two segments away. + /// + /// Initialised on the declaration rather than in `initState`: a `late final` + /// assigned in `initState` is never filled in on a `State` that already + /// exists, and hot reload reuses the existing `State`. See the identical note + /// in `Bottom_page.dart`. + late final AnimationController _pillAnim = AnimationController( + vsync: this, + // Matches the nav bar. A pill crossing two segments needs long enough for + // the eye to follow it; the 200ms the cross-fade used reads as a jump. + duration: const Duration(milliseconds: 380), + value: 1.0, + ); + + late final Animation _pillCurve = CurvedAnimation( + parent: _pillAnim, + curve: Curves.easeInOutCubic, + ); + + late Tween _pillTween = Tween( + begin: _slotOf(_inlineTabs()), + end: _slotOf(_inlineTabs()), + ); + + double get _pillPos => _pillTween.evaluate(_pillCurve); + + /// Trip indices rendered as inline segments, left to right. + /// + /// Three is the norm, not a guarantee — a hub can assign more. Past three the + /// bar keeps the first two segments and folds the rest into a "More" menu + /// rather than shrinking every tab until the labels are unreadable. When the + /// selection lives in the overflow it is pulled into the last inline slot, so + /// the rider can always see which trip he is looking at. + List _inlineTabs() { + final tabCount = widget.trips.length > TripTabs.maxInlineTabs + ? widget.trips.length + : TripTabs.maxInlineTabs; + final overflows = tabCount > TripTabs.maxInlineTabs; + final inlineCount = overflows ? TripTabs.maxInlineTabs - 1 : tabCount; + return [ + for (var i = 0; i < inlineCount; i++) i, + if (overflows && widget.selectedIndex >= inlineCount) + widget.selectedIndex, + ]; + } + + List _hiddenTabs(List inline) { + final tabCount = widget.trips.length > TripTabs.maxInlineTabs + ? widget.trips.length + : TripTabs.maxInlineTabs; + final overflows = tabCount > TripTabs.maxInlineTabs; + final inlineCount = overflows ? TripTabs.maxInlineTabs - 1 : tabCount; + return [ + for (var i = inlineCount; i < tabCount; i++) + if (!inline.contains(i)) i, + ]; + } + + /// Which *slot* the selection occupies. Not the same as the trip index once + /// an overflow menu is in play. + double _slotOf(List inline) { + final i = inline.indexOf(widget.selectedIndex); + return (i < 0 ? 0 : i).toDouble(); + } + + @override + void didUpdateWidget(TripTabs old) { + super.didUpdateWidget(old); + // Selection is owned by the page, not by this control, so the slide starts + // from here rather than from a tap handler. + final target = _slotOf(_inlineTabs()); + if ((target - _pillTween.end!).abs() < 0.001) return; + // Begin from where the pill actually *is*. Starting from the previous + // selection instead would snap it back and re-run the whole journey when the + // rider changes his mind mid-slide. + _pillTween = Tween(begin: _pillPos, end: target); + _pillAnim.forward(from: 0); + } + + @override + void dispose() { + _pillAnim.dispose(); + super.dispose(); + } + + /// The colour a segment settles on once the pill is over it. + Color _litColourOf(int tripIndex) { + if (tripIndex >= widget.trips.length) return ColorConstants.secondaryText; + return _percentOf(tripIndex) >= 100 + ? ColorConstants.acceptGreen + : ColorConstants.primary; + } + + /// The colour a segment sits at with the pill elsewhere. + /// + /// A finished trip stays green whether or not you are looking at it — "Trip 1, + /// 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; + return _percentOf(tripIndex) >= 100 + ? ColorConstants.acceptGreen + : ColorConstants.secondaryText; + } + + int _percentOf(int tripIndex) => widget.trips[tripIndex].completionPercent( + acceptedIds: widget.acceptedIds, + rejectedIds: widget.rejectedIds, + ); + + /// True when this trip has a stop in progress — the rider is on it now. + /// + /// Derived rather than stored: a trip is running when any of its stops is, and + /// [stopStateOf] already resolves that from the server status plus the local + /// accept/reject sets. + bool _isRunning(Trip trip) => trip.stops.any( + (s) => + stopStateOf( + s, + acceptedIds: widget.acceptedIds, + rejectedIds: widget.rejectedIds, + ) == + StopState.active, + ); + + @override + Widget build(BuildContext context) { + final inline = _inlineTabs(); + final hidden = _hiddenTabs(inline); + + // ── A segmented control on a track ── + // + // Three bordered chips became a tab bar became this: one light track holding + // three segments, with the tab you are looking at marked by a pill that + // slides between them. It is the iOS/Material segmented pattern, and it is + // the right one here because the choice is *exclusive and complete* — a + // rider is always looking at exactly one of his three trips, and a track + // makes that set visible as a set. + return Container( + // 16 — the app's base gutter, and the narrowest inset on this screen. + // + // The cards above sit on 28 and the route below on 32, so the track is + // deliberately the widest element in the column. Two reasons it reads + // right rather than misaligned: + // + // • Every card here carries a drop shadow with an 18–20pt blur, which + // bleeds past the card's box on each side. A card's *visible* edge is + // softly wider than its layout edge, while the track is a flat fill + // with a hard edge and no bleed — so matching the numbers exactly made + // the track look inset further than everything around it. It was + // measured at an identical 28 and still read as narrower. + // + // • A segmented control is a *container* for the three trips, not another + // card in the stack. Running it wider than the content it governs is + // the usual way that reads, and it gives each of the three segments + // more room for its label and status line. + margin: EdgeInsets.fromLTRB(16.w, 2.h, 16.w, 4.h), + padding: EdgeInsets.all(3.w), + decoration: BoxDecoration( + color: ColorConstants.neutralLight, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + // LayoutBuilder OUTSIDE IntrinsicHeight, not inside it. LayoutBuilder + // refuses to answer intrinsic-dimension queries, so nesting it under + // IntrinsicHeight throws rather than laying out. + child: LayoutBuilder( + builder: (context, box) { + // The overflow button is a fixed 48 plus a 6 gap, so the segments + // divide what is left. Without this the pill would sit off its tab + // whenever a hub assigned an unusual day. + final reserved = hidden.isEmpty ? 0.0 : 48.w + 6.w; + final slot = (box.maxWidth - reserved) / inline.length; + + return AnimatedBuilder( + animation: _pillAnim, + builder: (context, _) { + final pos = _pillPos; + + // The trailing underline travels *with* the pill as one object. + // Left in the segments it would have had to blink out under the + // old tab and in under the new one, which is the behaviour being + // removed. Its colour lerps between the two segments it is + // between, so crossing a finished trip shades red → green → red + // rather than snapping at the midpoint. + final lo = pos.floor().clamp(0, inline.length - 1); + final hi = pos.ceil().clamp(0, inline.length - 1); + final markColour = Color.lerp( + _litColourOf(inline[lo]), + _litColourOf(inline[hi]), + pos - pos.floor(), + )!; + + // No fixed height: the tab content is text, and a user with large + // font settings overflowed a hard 54.h by a few pixels. + // IntrinsicHeight sizes the row to its tallest tab instead, so + // every tab matches and nothing can overflow. + return IntrinsicHeight( + child: Stack( + children: [ + PositionedDirectional( + start: slot * pos, + top: 0, + bottom: 0, + width: slot, + child: Container( + margin: EdgeInsets.symmetric(horizontal: 2.w), + decoration: BoxDecoration( + // The same light glass red the nav bar uses for its + // selection. A white pill was doing the job with a + // drop shadow — fine on its own, but it made this the + // one selected-state in the app that was not the brand + // 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), + ), + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + // Keyed so tests can follow it across the strip. + // Identifying it by colour instead breaks the moment + // it travels over an unassigned trip, where it is + // deliberately grey rather than the brand red. + key: TripTabs.markerKey, + height: 3.h, + margin: EdgeInsets.only( + left: 12.w, + right: 12.w, + bottom: 4.h, + ), + decoration: BoxDecoration( + color: markColour, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + ), + ), + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for ( + var slotIndex = 0; + slotIndex < inline.length; + slotIndex++ + ) ...[ + Builder( + builder: (context) { + final i = inline[slotIndex]; + // How lit this segment is: 1 with the pill on it, + // 0 once the pill is a whole slot away. Cubed, so + // the glow stays tight around the pill — a linear + // ramp leaves every segment it crosses looking + // half-selected, and the control then reads as + // having no clear selection mid-slide. + final d = (pos - slotIndex).abs(); + final t = (1.0 - d).clamp(0.0, 1.0); + final lit = t * t * t; + + return Expanded( + child: _Tab( + label: Trip.tabLabel(i), + percent: i < widget.trips.length + ? _percentOf(i) + : 0, + assigned: i < widget.trips.length, + selected: i == widget.selectedIndex, + active: + i < widget.trips.length && + _isRunning(widget.trips[i]), + lit: lit, + restColour: _restColourOf(i), + litColour: _litColourOf(i), + onTap: () => widget.onSelect(i), + ), + ); + }, + ), + if (hidden.isNotEmpty && + slotIndex == inline.length - 1) + SizedBox(width: 6.w), + ], + if (hidden.isNotEmpty) + _OverflowMenu( + trips: widget.trips, + hidden: hidden, + onSelect: widget.onSelect, + acceptedIds: widget.acceptedIds, + rejectedIds: widget.rejectedIds, + ), + ], + ), + ], + ), + ); + }, + ); + }, + ), + ); + } +} + +class TripTabsHeader extends SliverPersistentHeaderDelegate { + final List trips; + final int selectedIndex; + final ValueChanged onSelect; + final Set acceptedIds; + final Set rejectedIds; + + /// Reserved height. Pass [extentFor] unless you have measured otherwise. + final double extent; + + const TripTabsHeader({ + required this.trips, + required this.selectedIndex, + required this.onSelect, + required this.extent, + this.acceptedIds = const {}, + this.rejectedIds = const {}, + }); + + /// Height to reserve for the bar at this context's text scale. + /// + /// Sums what a tab is actually made of — outer margin, border, padding, one + /// line of label, the gap, the progress underline — then adds a deliberate + /// safety margin. Over-reserving is the safe direction; see the class note. + static double extentFor(BuildContext context) { + final scaler = MediaQuery.textScalerOf(context); + final labelLine = scaler.scale(12.5.sp) * 1.4; + final statusLine = scaler.scale(10.5.sp) * 1.4; + return 6 + .h // Container margin, 2 top + 4 bottom + + + 6 + .w // track padding, 3 top + 3 bottom + + + 5 + .h // lead-in above the label + + + labelLine + + 1 + .h // gap between label and status + + + statusLine + + 4 + .h // gap above the running underline + + + 3 + .h // the underline + + + 4 + .h // tail below it + + + 6; // safety, so a rounding error adds air rather than a clip + } + + @override + double get minExtent => extent; + + @override + double get maxExtent => extent; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + return _PinnedSurface( + // True exactly when stops have scrolled underneath the bar. That is the + // moment it stops being part of the page and starts floating over it, so + // it is the moment it needs an edge — without one the tabs and the top of + // a stop card blur into each other. + floating: overlapsContent || shrinkOffset > 0, + child: TripTabs( + trips: trips, + selectedIndex: selectedIndex, + onSelect: onSelect, + acceptedIds: acceptedIds, + rejectedIds: rejectedIds, + ), + ); + } + + @override + bool shouldRebuild(TripTabsHeader old) => + old.extent != extent || + old.selectedIndex != selectedIndex || + old.trips != trips || + old.acceptedIds != acceptedIds || + old.rejectedIds != rejectedIds; +} + +/// Opaque backing for the pinned bar, with an edge that appears only once +/// content is passing underneath. +class _PinnedSurface extends StatelessWidget { + final bool floating; + final Widget child; + + const _PinnedSurface({required this.floating, required this.child}); + + @override + Widget build(BuildContext context) { + 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, + border: Border( + bottom: BorderSide( + color: floating ? ColorConstants.borderStrong : Colors.transparent, + width: 1, + ), + ), + ), + // Centred rather than stretched: the reserved extent over-estimates by + // design, so the spare pixels become air above and below instead of + // stretching the tabs into a shape they were not designed for. + child: Center(child: child), + ); + } +} + +class _Tab extends StatelessWidget { + final String label; + final int percent; + + /// False when the hub has not assigned this trip yet. + final bool assigned; + + /// The tab the rider is looking at. Used for semantics only — everything + /// painted follows [lit] instead, so the label tracks the pill rather than + /// flipping the instant it is tapped. + final bool selected; + + /// The trip actually under way. + final bool active; + + /// How close the travelling pill is, 0 → 1. + final double lit; + + /// Colour with the pill elsewhere, and colour with the pill on this tab. + final Color restColour; + final Color litColour; + + final VoidCallback onTap; + + const _Tab({ + required this.label, + required this.percent, + required this.selected, + required this.lit, + required this.restColour, + required this.litColour, + required this.onTap, + this.assigned = true, + this.active = false, + }); + + @override + Widget build(BuildContext context) { + final complete = assigned && percent >= 100; + + // ── One thing is marked, and it is the tab you are on ── + // + // Colour follows the pill. It briefly followed a separate "this trip is + // running" signal, and the result on a real screen was that Trip 1 sat + // permanently red and underlined while the rider stood on Trip 2 — two tabs + // claiming to be the important one, and the tap he had just made appearing + // to do nothing. A navigation control has to answer "where am I" before it + // answers anything else. + // + // Running is not lost, it is demoted: the tab still says "Active" in its + // status line, in whatever colour that tab's state calls for. That is the + // right weight for it — useful to know, not something to fight the selection + // for the eye. + final Color accent = Color.lerp(restColour, litColour, lit)!; + + final String status = !assigned + ? 'Not set' + : complete + ? 'Done' + : active + ? 'Active' + : percent > 0 + ? '$percent% done' + : '$percent%'; + + return Semantics( + button: true, + selected: selected, + label: assigned + ? '$label, $status${active ? ', in progress' : ''}' + : '$label, not assigned yet', + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 5.h), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 1.h), + // Progress as words, not a bar. The underline is reserved for "this + // is the one you are on", and one mark cannot mean two things — but + // "how far through is each trip" is why tabs beat a dropdown here, + // so the figure has to survive somewhere legible. + Text( + status, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.5.sp, + fontWeight: FontWeight.w700, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 4.h), + // Where the underline used to be drawn. It now travels with the + // pill, but the space it occupied is still reserved here so every + // segment keeps the height `TripTabsHeader.extentFor` reserves for + // it and nothing shifts. + SizedBox(height: 3.h), + SizedBox(height: 4.h), + ], + ), + ), + ); + } +} + +/// "More" button holding trips 4+ when a hub assigns an unusual day. +class _OverflowMenu extends StatelessWidget { + final List trips; + final List hidden; + final ValueChanged onSelect; + final Set acceptedIds; + final Set rejectedIds; + + const _OverflowMenu({ + required this.trips, + required this.hidden, + required this.onSelect, + required this.acceptedIds, + required this.rejectedIds, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: '${hidden.length} more trips', + child: Material( + color: Colors.white, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: PopupMenuButton( + onSelected: onSelect, + tooltip: 'More trips', + position: PopupMenuPosition.under, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + itemBuilder: (_) => [ + for (final i in hidden) + PopupMenuItem( + value: i, + child: Row( + children: [ + Text( + Trip.tabLabel(i), + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w800, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(width: 8.w), + Text( + i < trips.length ? trips[i].slotLabel : 'Not assigned', + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + child: Container( + width: 48.w, + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.expand_more_rounded, + size: 22.sp, + color: ColorConstants.slateText, + ), + Text( + '+${hidden.length}', + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w800, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/orders/orderstaus_button.dart b/lib/views/Dashboard/orders/orderstaus_button.dart new file mode 100644 index 0000000..fd339c4 --- /dev/null +++ b/lib/views/Dashboard/orders/orderstaus_button.dart @@ -0,0 +1,741 @@ +import 'package:flutter/material.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'dart:async'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +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'; + +/// ------------------------------ +/// 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; + + /// Secondary per-booking actions rendered as compact icon buttons at the + /// start of the action bar, so the card shows a single row of controls + /// (Call · Details · Reject · Slide-to-Accept) instead of two stacked rows. + /// Null hides that button. They stay tappable even while the primary + /// slide/accept is disabled (e.g. multi-select mode) — a rider still needs to + /// call the customer or read the parcel details. + final VoidCallback? onCall; + final VoidCallback? onDetails; + + const OrderStatusRow({ + super.key, + required this.currentStatus, + required this.onStatusChange, + this.enabled = true, + this.onCall, + this.onDetails, + }); + + @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; + } + + 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, + sheetAnimationStyle: kMilerSheetStyle, + 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 service 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 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, + ), + ), + ), + ), + ), + 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); + } + }, + ), + ], + ), + ); + }, + ), + ); + }, + ); + } + + // ------------------------------ + // CANCEL SHEET + // ------------------------------ + void _showCancelSheet(BuildContext context) { + showModalBottomSheet( + context: context, + sheetAnimationStyle: kMilerSheetStyle, + 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 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.", + ), + ), + ); + }, + ), + ], + ), + ); + }, + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final String currentStatus = _effectiveStatus; + final bool showCancel = + currentStatus == "ARRIVED" || currentStatus == "PICKED"; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 2, 16, 12), + child: Stack( + children: [ + // Single action bar: [Call] [Details] [Reject/Cancel] [Slide → …]. + // The three leading actions are compact icon buttons; the slider keeps + // the remaining width because it's the primary action and needs room + // to be draggable. + Row( + children: [ + if (widget.onCall != null) ...[ + _IconAction( + icon: Icons.phone_rounded, + color: ColorConstants.acceptGreen, + tooltip: 'Call customer', + onTap: widget.onCall, + ), + const SizedBox(width: 8), + ], + if (widget.onDetails != null) ...[ + _IconAction( + icon: Icons.receipt_long_rounded, + color: ColorConstants.onSurfaceVariant, + tooltip: 'Booking details', + onTap: widget.onDetails, + ), + const SizedBox(width: 8), + ], + + // LEFT PRIMARY (Reject / Cancel) — compact tonal destructive + _IconAction( + icon: showCancel ? Icons.undo_rounded : Icons.close_rounded, + color: showCancel + ? ColorConstants.warning + : ColorConstants.error, + tooltip: showCancel ? 'Cancel pickup' : 'Reject booking', + onTap: () { + if (showCancel) { + _showCancelSheet(context); + } else { + _showRejectSheet(context); + } + }, + ), + const SizedBox(width: 10), + + // RIGHT BUTTON (ACCEPT / ARRIVED / PICKED) — takes remaining width + 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.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Center( + child: Container( + height: 46, + width: 46, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + padding: const EdgeInsets.all(10), + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + ColorConstants.primary, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// Reusable rounded pill action button used by the booking status row. +/// [filled] gives a solid brand-colored button (Accept side); otherwise it +/// renders as a soft tonal button with an outline (Reject / Cancel side). +class StatusPill extends StatelessWidget { + final String label; + final IconData icon; + final Color color; + final bool filled; + final bool enabled; + final VoidCallback? onTap; + + const StatusPill({ + super.key, + required this.label, + required this.icon, + required this.color, + this.filled = true, + this.enabled = true, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final Color fg = !enabled + ? ColorConstants.disabledFill + : (filled ? Colors.white : color); + final Color bg = !enabled + ? ColorConstants.neutralLight + : (filled ? color : color.withValues(alpha: 0.10)); + + return Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: InkWell( + onTap: enabled ? onTap : null, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: Ink( + height: ButtonSizes.secondary, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: filled + ? null + : Border.all( + color: color.withValues(alpha: enabled ? 0.22 : 0.0), + ), + boxShadow: (filled && enabled) + ? [ + BoxShadow( + color: color.withValues(alpha: 0.28), + blurRadius: 12, + offset: const Offset(0, 5), + ), + ] + : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: fg, size: 18), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: 14.5, + color: fg, + fontWeight: FontWeight.w700, + letterSpacing: 0.3, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Compact square icon button (50×50) used for the secondary actions in the +/// booking action bar — Call, Details, Reject/Cancel. Tonal fill + the action's +/// colour, so the destructive Reject reads red without a full-width label +/// eating horizontal space the slider needs. +class _IconAction extends StatelessWidget { + final IconData icon; + final Color color; + final VoidCallback? onTap; + final String? tooltip; + + const _IconAction({ + required this.icon, + required this.color, + this.onTap, + this.tooltip, + }); + + @override + Widget build(BuildContext context) { + final Widget button = Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: Ink( + height: ButtonSizes.secondary, + width: ButtonSizes.secondary, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: color.withValues(alpha: 0.20)), + ), + child: Icon(icon, color: color, size: 20), + ), + ), + ); + + if (tooltip == null) return button; + return Tooltip(message: tooltip!, child: button); + } +} + +/// ------------------------------ +/// STATUS BUTTON LOGIC (NO PICKUP) +/// ------------------------------ +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 ColorConstants.warning; + case "PICKED": + return ColorConstants.primary; + default: + return ColorConstants.acceptGreen; + } + } + + IconData _iconForStatus() { + switch (_buttonText) { + case "ARRIVED": + return Icons.inventory_2_rounded; + case "PICKED": + return Icons.task_alt_rounded; + case "ACCEPTED": + return Icons.directions_bike_rounded; + default: + return Icons.check_rounded; + } + } + + // Single-step next status. Null = terminal (PICKED) or unknown — nothing to + // advance to. + String? get _nextStatus { + switch (_buttonText) { + case "ACCEPT": + return "ACCEPTED"; + case "ACCEPTED": + return "ARRIVED"; + case "ARRIVED": + return "PICKED"; + default: + return null; + } + } + + // Friendly action verb shown on the slider, e.g. "Slide → Accept" instead of + // the raw backend status token ("ACCEPTED"). + String get _actionLabel { + switch (_nextStatus) { + case "ACCEPTED": + return "Accept"; + case "ARRIVED": + return "Arrived"; + case "PICKED": + return "Pick Up"; + default: + return _nextStatus ?? ""; + } + } + + bool _advancing = false; + + /// Advances one step. PICKED requires a proof photo. Runs the same logic the + /// old bottom-sheet slider did — just triggered inline, in one gesture. + Future _performAdvance(dynamic sliderController) async { + final next = _nextStatus; + if (next == null || _advancing) return; + _advancing = true; + try { + if (next == "PICKED") { + final XFile? photo = await ImagePicker().pickImage( + source: ImageSource.camera, + imageQuality: 50, + ); + if (photo != null) { + await widget.onStatusChange(next, proofImagePath: photo.path); + } + } else { + await widget.onStatusChange(next); + } + } finally { + _advancing = false; + try { + sliderController?.reset(); + } catch (_) {} + } + } + + @override + Widget build(BuildContext context) { + final next = _nextStatus; + final color = _getButtonColor(); + + // Terminal / unknown status → a non-interactive pill (nothing to swipe). + if (next == null) { + return Expanded( + child: StatusPill( + label: _buttonText, + icon: _iconForStatus(), + color: color, + filled: true, + enabled: false, + ), + ); + } + + // Inline slide-to-advance — one gesture on the card, no bottom sheet. + return Expanded( + child: IgnorePointer( + ignoring: !widget.enabled, + child: Opacity( + opacity: widget.enabled ? 1 : 0.5, + child: SizedBox( + height: ButtonSizes.secondary, + child: SlideToSubmit.custom( + height: ButtonSizes.secondary, + sliderWidth: 38, + padding: const EdgeInsets.all(5), + backgroundDecoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + foregroundDecoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + slider: Center( + child: Container( + height: 40, + width: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Icon( + Icons.arrow_forward_rounded, + size: 18, + color: color, + ), + ), + ), + hint: Align( + alignment: Alignment.center, + child: Text( + 'Slide → $_actionLabel', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + letterSpacing: 0.2, + color: color, + ), + ), + ), + onSubmit: (controller) async { + await _performAdvance(controller); + }, + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/pickups/card.dart b/lib/views/Dashboard/pickups/card.dart new file mode 100644 index 0000000..c0e642a --- /dev/null +++ b/lib/views/Dashboard/pickups/card.dart @@ -0,0 +1,802 @@ +part of 'pickups.dart'; + +// ───────────────────────────────────────────────────────────────────────────── +// UNIFIED STOP CARD +// +// One card renders all three operational modes — PICKUP, DELIVERY, and +// PICKUP & DELIVERY (combined) — because a rider scanning a route list must +// not have to learn three different layouts. The mode changes the accent, the +// badge, and the action breakdown; the *shape* never moves. +// +// Reading order, top to bottom, matches the order the rider asks the questions: +// 1. WHAT kind of stop, which number, and how far? → meta line +// 2. WHO and WHERE? → name + address +// 3. WHAT exactly do I do here? → action line +// 4. GO. → primary CTA +// +// Each of those is a ROW OF TEXT, not a panel. See the note inside build(). +// +// Thumb zone: the CTA and the call button live in the bottom band of the card. +// Everything above is read-only — nothing the rider needs to *press* sits in +// the top 60%, which is where his hand covers the screen while reading. +// ───────────────────────────────────────────────────────────────────────────── +class PickupCard extends StatelessWidget { + final Map item; + final int displayStep; + + /// Live straight-line distance from the rider to this stop, in metres. + /// Null when no usable fix exists — the strip then says so instead of + /// inventing a zero. + final double? distanceMeters; + + /// The rider's current ground speed in m/s, if known. Drives the ETA; + /// [RouteMetricsHelper] falls back to an urban average when it's unusable. + final double? riderSpeedMps; + + final bool enabled; + final bool isSkipped; + + const PickupCard({ + super.key, + required this.item, + required this.displayStep, + this.distanceMeters, + this.riderSpeedMps, + this.enabled = true, + this.isSkipped = false, + }); + + String _val(List keys, [String fallback = '']) { + for (final k in keys) { + final v = item[k]; + if (v != null && v.toString().trim().isNotEmpty) return v.toString(); + } + return fallback; + } + + /// Minimum 48dp tap targets, enforced against the raw logical pixel value so + /// a small-screen `.h` scale-down can never shrink below the floor. + /// + /// Was 52dp with a 56dp CTA. The card's text carries the reading now — the + /// name, the address and the action line all went up a step — and a 56dp + /// button under bigger type made the bottom band the loudest thing on a card + /// whose job is to be scanned, not pressed. 48dp is still Material's floor + /// for a thumb, so nothing is harder to hit; the button just stops competing + /// with the words above it. + static double _tapTarget(double scaled) => math.max(48.0, scaled); + + @override + Widget build(BuildContext context) { + final StopKind kind = stopKindOf(item); + + final String customer = _val([ + 'pickupcustomer', + 'tenantname', + ], kind.hasDelivery ? 'Delivery point' : 'Pickup point'); + final String address = _val([ + 'pickupaddress', + 'PickupAddress', + ], 'Address not available'); + final String orderId = _val(['orderid']); + final String notes = _val(['notes', 'Notes']); + final String phone = _val(['pickupcontactno']); + + final int deliverQty = deliveryParcelCount(item); + final int collectQty = pickupParcelCount(item); + final double cash = stopCollectionAmount(item); + + // ── The card's one accent, driven by STATE not type ── + // + // 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. + // + // 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; + + return Slidable( + key: ValueKey('slidable_$orderId'), + startActionPane: enabled + ? ActionPane( + motion: const ScrollMotion(), + extentRatio: 0.35, + children: [ + SlidableAction( + 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, + label: isSkipped ? 'Resume' : 'Start', + ), + ], + ) + : null, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + 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( + 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, + children: [ + Container( + width: 4.w, + color: enabled ? accent : accent.withValues(alpha: 0.35), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── One card, one box ── + // + // 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". + // + // 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, + ), + ], + ), + ), + + // ── 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), + ], + ], + ), + ), + + // ── 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, + ); + } + }, + ), + ], + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + /// Everything the app knows about this stop, in the same sheet Home uses. + /// + /// Deliberately the same widget rather than a Bookings-specific one: the two + /// screens show the same stop, and two sheets would drift. + void _openDetail(BuildContext context) { + final parentState = context.findAncestorStateOfType<_MyPickupsState>(); + StopDetailSheet.show( + context, + stop: item, + stopNumber: displayStep, + riderLat: parentState?._currentLocation?.latitude, + riderLng: parentState?._currentLocation?.longitude, + ); + } + + /// Opens the stop's map/navigation screen. + /// + /// [gated] applies the on-duty check — the swipe action can fire from a + /// resting thumb, so it verifies duty first; the explicit CTA does not + /// re-ask because the card is only enabled for an on-duty rider anyway. + Future _startStop(BuildContext context, {bool gated = false}) async { + final parentState = context.findAncestorStateOfType<_MyPickupsState>(); + if (parentState == null) return; + + if (gated) { + if (!await parentState.ensureOnlineForAction()) return; + if (!context.mounted) return; + } + + await openScreen( + context, + _PickupMapScreen(pickup: item, parentState: parentState), + swipeToGoBack: false, + ); + } +} + +// ── Distance + ETA, as one chip ────────────────────────────────────────────── +// +// Was a full-width amber panel with its own border, on the argument that this +// is the one number that changes while the rider is moving. The argument was +// right; the panel was not the way to honour it. A tinted band 40px tall +// pushed the address and the button down on every card in the list, and in +// sunlight an amber fill on white reads as a grey smudge anyway — the very +// thing the card's own comments warned about for badges. +// +// Bare text on the meta line was the correction, and it went one step too far: +// the two live figures ended up sharing a line with the type badge and the stop +// number in a similar weight, so nothing marked where the static half of the +// line ended and the moving half began. A single pill fixes that at no cost in +// height — it wraps BOTH figures, so distance and ETA read as one fact about +// 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. +class _DistanceEta extends StatelessWidget { + final double? meters; + final double? speedMps; + final bool muted; + + const _DistanceEta({ + required this.meters, + required this.speedMps, + this.muted = false, + }); + + @override + Widget build(BuildContext context) { + final distance = RouteMetricsHelper.formatDistance(meters); + final travel = RouteMetricsHelper.formatDuration( + RouteMetricsHelper.travelTime(meters, liveSpeedMps: speedMps), + ); + if (distance == '—') return const SizedBox.shrink(); + + final Color fg = muted + ? 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, + ), + ), + ], + ), + ); + } +} + +// ── What to do here ────────────────────────────────────────────────────────── +// +// Replaces the bordered "ACTION REQUIRED" panel, its all-caps heading, and the +// two rows inside it that each carried a 24px tinted icon tile — four boxes to +// say "Collect 3 parcels, cash ₹340". +// +// One line of text says the same thing. A combined stop still gets both jobs, +// separated by a bullet and each in its own leg colour, so the "TWO jobs here" +// signal the panel existed for survives: it is now carried by colour and by +// the word "and", which a rider reads faster than he parses a stacked panel. +class _ActionLine extends StatelessWidget { + final StopKind kind; + final int deliverQty; + final int collectQty; + final double cash; + final bool skipped; + + const _ActionLine({ + required this.kind, + required this.deliverQty, + required this.collectQty, + required this.cash, + required this.skipped, + }); + + static String _money(double v) { + if (v >= 100) return '₹${v.round()}'; + final rounded = (v * 100).round() / 100; + return rounded == rounded.roundToDouble() + ? '₹${rounded.round()}' + : '₹${rounded.toStringAsFixed(2)}'; + } + + @override + Widget build(BuildContext context) { + final spans = []; + + TextStyle style(Color c) => TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w800, + color: c, + fontFamily: FontConstants.fontFamily, + ); + + // Parcel legs are neutral; only the cash is coloured. These used to be + // royal blue and pink respectively, which is colour spent on naming a leg + // that the word beside it already names — and it made the line read as three + // unrelated fragments. Matches [_MetaLine] on the Home stop row. + if (kind.hasDelivery) { + final n = deliverQty > 0 ? deliverQty : 1; + spans.add( + TextSpan(text: 'Deliver $n', style: style(ColorConstants.slateText)), + ); + } + if (kind.hasPickup) { + if (spans.isNotEmpty) { + spans.add( + TextSpan(text: ' · ', style: style(ColorConstants.borderStrong)), + ); + } + final n = collectQty > 0 ? collectQty : 1; + spans.add( + TextSpan(text: 'Collect $n', style: style(ColorConstants.slateText)), + ); + } + if (spans.isEmpty) return const SizedBox.shrink(); + + if (cash > 0) { + spans.add( + TextSpan(text: ' · ', style: style(ColorConstants.secondaryText)), + ); + spans.add( + TextSpan( + // `acceptGreen` is a fill colour; as text on the card fill it measures + // 2.28:1. See [ColorConstants.moneyGreen]. + text: 'Collect ${_money(cash)}', + style: style(ColorConstants.moneyGreen), + ), + ); + } + if (skipped) { + spans.add( + TextSpan( + text: ' · Skipped earlier', + style: style(ColorConstants.warning), + ), + ); + } + + return Padding( + padding: EdgeInsets.fromLTRB(14.w, 7.h, 12.w, 0), + child: Text.rich( + TextSpan(children: spans), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + } +} + +class _CallButton extends StatelessWidget { + final String phone; + const _CallButton({required this.phone}); + + @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', + 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 + // number so he can dial it himself. + final ok = await launchPhoneDialer(phone); + if (!ok && context.mounted) { + AppFeedback.error(context, "Couldn't open the dialer — $phone"); + } + }, + ); + } +} + +// ── Square icon button with a guaranteed tap target ────────────────────────── +class _SquareIconButton extends StatelessWidget { + final IconData icon; + final double size; + final Color background; + final Color border; + final Color iconColor; + final String semanticLabel; + final VoidCallback onTap; + + const _SquareIconButton({ + required this.icon, + required this.size, + required this.background, + required this.border, + required this.iconColor, + required this.semanticLabel, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: semanticLabel, + child: Material( + color: background, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: border, width: 1.5), + ), + child: Icon(icon, color: iconColor, size: 22.sp), + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/pickups/collect_payment.dart b/lib/views/Dashboard/pickups/collect_payment.dart new file mode 100644 index 0000000..7dff3a6 --- /dev/null +++ b/lib/views/Dashboard/pickups/collect_payment.dart @@ -0,0 +1,1273 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:qr_flutter/qr_flutter.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/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'; +import 'package:miler/views/helpers/widgets/page_transitions.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// COLLECT PAYMENT — money taken at the door on the shipper's behalf. +/// +/// Miler is the carrier, not the merchant. Nothing collected here is Miler's +/// revenue: it is the shipper's money, held by the rider for the length of a +/// shift and deposited at the hub. Every line on this screen is written from +/// that position, and the custody note at the bottom is the point of the page +/// as much as the slider is. +/// +/// The screen this replaces got the central fact wrong. It computed +/// +/// _pickupCharge = weight * 15.0 // or 30.0 if weight was blank +/// +/// — a freight charge invented on the handset from a number the rider had +/// typed a screen earlier, presented to the customer as the amount owed. The +/// booking's real `collectionamt` was never read. Two riders weighing the same +/// parcel differently would demand different money for the same consignment, +/// and a rider who skipped the optional weight field would demand ₹30 from +/// someone who owed nothing. +/// +/// So: the amount comes from the booking, it is not editable, and it is not +/// derived from anything measured at the door. What the rider CAN record is +/// what actually happened — the method, and a short payment with a reason. +/// That is the one thing he genuinely knows and the office does not. +/// +/// Card was dropped with the arithmetic. A first-mile miler carries a phone +/// and a bag, not a card terminal, and an option that cannot be honoured is +/// worse than no option at all. +/// +/// ── One payment surface, and the QR lives on it ── +/// +/// UPI used to be a *dialog*, thrown up after the rider had already chosen a +/// method chip and slid a confirm: a bare QR, an amount, and a "Payment +/// Received" button. Nothing on it said which consignment was being paid for, +/// so the customer — who is being asked to hand over real money at his own +/// doorstep — was shown a code and a number and asked to trust both. If two +/// bookings were being settled at one shop, nothing on that dialog could tell +/// them apart. +/// +/// The QR now renders inline, on the page that already carries the order, the +/// parcels and the amount, one tap after choosing UPI. That is a tap *fewer* +/// than the dialog it replaces (choose → slide → confirm becomes choose → +/// slide) and the customer can see what he is paying for while he scans, which +/// is what every payment surface worth trusting — Uber's fare breakdown, a +/// Rapido receipt, any UPI merchant screen — puts next to the amount. +/// ───────────────────────────────────────────────────────────────────────── +class CollectPaymentPage extends StatefulWidget { + final Map pickup; + + /// What the verification step recorded. Read-only here — this page never + /// derives money from it, it only shows the consignment it belongs to. + final Map verificationData; + + /// Method to open on, when the caller already asked. The Bookings confirm + /// sheet does: the rider picks "Cash" or "QR" there, and re-asking him the + /// same question on arrival would be the extra tap this page exists to + /// remove. Null opens on cash, which is what most doors pay in. + final String? initialMethod; + + const CollectPaymentPage({ + super.key, + required this.pickup, + this.verificationData = const {}, + this.initialMethod, + }); + + @override + State createState() => _CollectPaymentPageState(); +} + +/// The UPI intent a payment QR encodes. +/// +/// Top-level and pure so it can be tested directly: the QR widget keeps its +/// payload private, and this string is the one part of the payment flow where a +/// silent mistake — a wrong amount, an unescaped shop name that truncates the +/// payee — takes money from a real person and cannot be undone from the app. +/// +/// The amount is baked in so the customer cannot mistype it, and the +/// consignment rides in the note so the payment is identifiable in his own bank +/// statement weeks later, which is what makes a dispute resolvable at all. +String buildUpiUri({ + required String payeeVpa, + required String payeeName, + required String orderId, + required double amount, +}) { + final note = orderId.trim().isEmpty + ? 'Miler pickup' + : 'Miler pickup $orderId'; + return 'upi://pay' + '?pa=${Uri.encodeComponent(payeeVpa)}' + '&pn=${Uri.encodeComponent(payeeName)}' + '&tn=${Uri.encodeComponent(note)}' + '&am=${amount.toStringAsFixed(2)}' + '&cu=INR'; +} + +enum _Method { cash, upi } + +extension _MethodUi on _Method { + String get label => this == _Method.cash ? 'Cash' : 'UPI / QR'; + + String get blurb => this == _Method.cash + ? 'Notes and coins, into your bag' + : 'Customer scans and pays'; + + IconData get icon => + this == _Method.cash ? Icons.payments_rounded : Icons.qr_code_2_rounded; + + Color get accent => this == _Method.cash + ? ColorConstants.acceptGreen + : ColorConstants.upiPurple; + + String get wireName => name; +} + +class _CollectPaymentPageState extends State { + late final double _due; + + /// The separate freight charge, when the booking carries one distinct from + /// the collection amount. Shown as a line, never added to anything — see + /// [_breakdown]. + late final double _pickupCharge; + + late final String _customerName; + late final String _shipper; + late final String _orderId; + late final String _address; + late final StopKind _kind; + late final int _parcels; + late final String _weight; + late final String _itemNote; + + /// Where a UPI payment goes. Read from the booking when the backend names a + /// payee, so a shipper collecting into its own VPA is honoured; otherwise the + /// hub's, because the rider is the one who reconciles at the end of the day. + late final String _payeeVpa; + + _Method _method = _Method.cash; + + /// The customer paid less than the booking says. Off by default: a short + /// payment is the exception, and defaulting to it invites the rider to close + /// every stop as short. + bool _short = false; + final TextEditingController _received = TextEditingController(); + final TextEditingController _shortReason = TextEditingController(); + + /// UPI reference (UTR). Optional — the rider often cannot see it, and + /// blocking on a number printed on the customer's screen would strand him. + final TextEditingController _reference = TextEditingController(); + + bool _submitting = false; + + /// Hub VPA of last resort. Replace with the real one before release — a QR + /// that resolves to nothing is worse than no QR, because the customer + /// believes he has paid. + static const String _fallbackVpa = 'miler@upi'; + + @override + void initState() { + super.initState(); + final d = widget.pickup; + final v = widget.verificationData; + + _due = stopCollectionAmount(d); + _pickupCharge = _amountOf(d['pickupamt']); + _customerName = (d['pickupcustomer'] ?? d['PickupCustomer'] ?? 'Customer') + .toString(); + _shipper = (d['tenantname'] ?? d['TenantName'] ?? '').toString().trim(); + _orderId = (d['orderid'] ?? '').toString(); + _address = (d['pickupaddress'] ?? d['PickupAddress'] ?? '') + .toString() + .trim(); + _kind = stopKindOf(d); + + // What the rider actually took, when verification counted it — that is a + // truer description of what is being paid for than the manifest quantity. + final counted = (v['pickup'] is Map) ? v['pickup']['collected'] : null; + _parcels = counted is int + ? counted + : (int.tryParse('${counted ?? ''}') ?? pickupParcelCount(d)); + + _weight = (v['weight'] ?? '').toString().trim(); + _itemNote = (v['description'] ?? '').toString().trim(); + + _payeeVpa = + _firstNonEmpty(d, const [ + 'upiid', + 'upiId', + 'upi_id', + 'payeevpa', + 'payeeVpa', + 'vpa', + 'tenantupi', + ]) ?? + _fallbackVpa; + + if ((widget.initialMethod ?? '').toLowerCase().contains('qr') || + (widget.initialMethod ?? '').toLowerCase().contains('upi')) { + _method = _Method.upi; + } + } + + static double _amountOf(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toDouble(); + final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), ''); + return double.tryParse(cleaned) ?? 0; + } + + static String? _firstNonEmpty(Map m, List keys) { + for (final k in keys) { + final v = (m[k] ?? '').toString().trim(); + if (v.isNotEmpty) return v; + } + return null; + } + + @override + void dispose() { + _received.dispose(); + _shortReason.dispose(); + _reference.dispose(); + super.dispose(); + } + + double get _collected { + if (!_short) return _due; + return double.tryParse(_received.text.trim()) ?? 0; + } + + String? get _blockingHint { + if (!_short) return null; + final entered = double.tryParse(_received.text.trim()); + if (entered == null) return 'Enter how much you actually took'; + if (entered < 0) return 'That is not an amount'; + if (entered >= _due) { + return 'A short payment is less than ₹${_fmt(_due)}'; + } + if (_shortReason.text.trim().length < 3) { + return 'Say why the customer paid less'; + } + return null; + } + + String get _upiUri => buildUpiUri( + payeeVpa: _payeeVpa, + payeeName: _shipper.isEmpty ? 'Miler Logistics' : _shipper, + orderId: _orderId, + amount: _due, + ); + + void _finish({required bool paid}) { + if (_submitting) return; + if (paid && _blockingHint != null) { + _toast(_blockingHint!); + return; + } + setState(() => _submitting = true); + + Navigator.pop(context, { + 'paid': paid, + // Kept for the old call sites, which only ever checked truthiness. + 'verified': true, + 'method': paid ? _method.wireName : null, + 'amountDue': _due, + 'amountCollected': paid ? _collected : 0.0, + 'shortBy': paid ? (_due - _collected) : _due, + 'shortReason': _short ? _shortReason.text.trim() : '', + 'reference': (paid && _method == _Method.upi) + ? _reference.text.trim() + : '', + 'orderId': _orderId, + }); + } + + void _declinePayment() async { + final ok = await showModalBottomSheet( + context: context, + sheetAnimationStyle: kMilerSheetStyle, + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (_) => _ConfirmSheet( + title: 'Customer is not paying?', + body: + 'The stop stays open and the office is told nothing was ' + 'collected. Only do this if the customer has actually refused or ' + 'cannot pay right now.', + confirmLabel: 'Nothing collected', + ), + ); + if (ok == true && mounted) _finish(paid: false); + } + + /// Every message on this screen goes through the one feedback shape. + /// See [AppFeedback]. + void _toast(String message) => AppFeedback.error(context, message); + + + static String _fmt(double v) => + v == v.roundToDouble() ? v.round().toString() : v.toStringAsFixed(2); + + @override + Widget build(BuildContext context) { + // Defensive: the caller should not open this page for a stop with nothing + // to collect, but a booking can lose its amount between list and door. + if (_due <= 0) return _nothingToCollect(); + + return Scaffold( + // White page, tinted panels — the same inversion the verification screens + // it follows now use. See the note there. + 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)), + body: Column( + children: [ + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.fromLTRB(14.w, 14.h, 14.w, 24.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _amountCard(), + SizedBox(height: 14.h), + _orderCard(), + SizedBox(height: 14.h), + _methodCard(), + SizedBox(height: 14.h), + _shortCard(), + SizedBox(height: 14.h), + _custodyNote(), + ], + ), + ), + ), + _bottomBar(), + ], + ), + ); + } + + // ── The amount ───────────────────────────────────────────────────────── + // + // Read from the booking and shown as a fact, not a field. The rider is a + // courier, not a cashier: he does not get to price the consignment at the + // door, and a number he can edit is a number he will be argued into + // changing. + Widget _amountCard() { + return Container( + width: double.infinity, + padding: EdgeInsets.fromLTRB(18.w, 20.h, 18.w, 18.h), + decoration: BoxDecoration( + color: ColorConstants.cardSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'DUE AT THIS STOP', + style: TextStyle( + fontSize: 10.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 6.h), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + '₹${_fmt(_due)}', + style: TextStyle( + fontSize: 42.sp, + fontWeight: FontWeight.w800, + letterSpacing: -1.6, + height: 1.05, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(height: 4.h), + Text( + 'from $_customerName', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ..._breakdown(), + ], + ), + ); + } + + /// What makes up the figure above, when the booking actually says. + /// + /// ── Why this is a list of what the booking carries, and not a sum ── + /// + /// It is tempting to add the collection amount to the pickup charge and print + /// a total. That is exactly the mistake the screen before this one made, from + /// the other end: the app deciding what the customer owes. The office sets + /// the figure, `stopCollectionAmount` reads it, and everything here is + /// *explanation* of a number that has already been decided elsewhere. So the + /// components are listed where they are known, and the due line repeats the + /// authoritative figure rather than deriving it — if the two ever disagree, + /// the rider takes what the booking says, and the discrepancy is the office's + /// to reconcile, not his to arbitrate at a doorstep. + List _breakdown() { + final lines = <(String, double)>[ + if (_pickupCharge > 0 && _pickupCharge != _due) + ('Pickup charge', _pickupCharge), + ]; + if (lines.isEmpty) return const []; + + return [ + SizedBox(height: 12.h), + Container(height: 1, color: ColorConstants.borderSubtle), + SizedBox(height: 10.h), + for (final line in lines) ...[ + _breakdownRow(line.$1, '₹${_fmt(line.$2)}'), + SizedBox(height: 7.h), + ], + _breakdownRow('Due now', '₹${_fmt(_due)}', strong: true), + ]; + } + + Widget _breakdownRow(String label, String value, {bool strong = false}) { + return Row( + children: [ + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: strong ? FontWeight.w800 : FontWeight.w600, + color: strong + ? ColorConstants.slateText + : ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + Text( + value, + style: TextStyle( + fontSize: strong ? 14.sp : 12.5.sp, + fontWeight: FontWeight.w800, + color: strong + ? ColorConstants.slateText + : ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ); + } + + // ── What the money is for ────────────────────────────────────────────── + // + // ── Why a payment screen carries the consignment ── + // + // The customer is being asked, at his own door, to hand over money or scan a + // stranger's QR. "₹450" alone does not earn that: it is a number on a phone + // held by someone he met a minute ago. What makes a payment surface + // trustworthy is that it names the thing being paid for, and every payment + // screen the rider's customers already use — Uber's fare breakdown, a Rapido + // receipt, a merchant's UPI screen — does exactly this. + // + // It also settles the rider's own most common argument. At a shop with two + // bookings open, "which one is this for?" was unanswerable from the old + // dialog; here it is the second line on the page. + 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), + 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), + ]; + + return _card( + title: 'What this pays for', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < facts.length; i++) ...[ + if (i > 0) SizedBox(height: 9.h), + _metaRow(facts[i].$1, facts[i].$2, facts[i].$3), + ], + if (_address.isNotEmpty) ...[ + SizedBox(height: 11.h), + Container(height: 1, color: ColorConstants.borderSubtle), + SizedBox(height: 10.h), + Text( + _address, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.sp, + height: 1.35, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ], + ), + ); + } + + Widget _metaRow(IconData icon, String label, String value) { + return Row( + children: [ + Icon(icon, size: 14.sp, color: ColorConstants.secondaryText), + SizedBox(width: 7.w), + // Flexible: at a large system font "On behalf of" plus a shipper name + // is wider than the card, and the label is the half that can afford to + // give — the value is the fact. + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 10.w), + Expanded( + child: Text( + value, + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ); + } + + // ── How it was paid ──────────────────────────────────────────────────── + Widget _methodCard() { + return _card( + title: 'How did they pay?', + child: Column( + children: [ + for (final m in _Method.values) ...[ + if (m != _Method.values.first) SizedBox(height: 9.h), + _methodTile(m), + ], + if (_method == _Method.upi) ...[ + SizedBox(height: 14.h), + _qrPanel(), + SizedBox(height: 12.h), + _field( + controller: _reference, + hint: 'UPI reference (optional)', + icon: Icons.tag_rounded, + keyboard: TextInputType.number, + formatters: [FilteringTextInputFormatter.digitsOnly], + ), + ], + ], + ), + ); + } + + /// The QR, on the page, with the amount over it and the payee under it. + /// + /// Turned toward the customer — so everything he needs to check before he + /// scans is inside one glance of the code itself: what he is paying, and to + /// whom. The amount is baked into the intent, so he cannot pay the wrong + /// figure by mistyping it. + Widget _qrPanel() { + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 16.h, horizontal: 14.w), + decoration: BoxDecoration( + color: ColorConstants.upiPurple.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Column( + children: [ + Text( + 'Show this to the customer', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w800, + color: ColorConstants.upiPurple, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 12.h), + Container( + padding: EdgeInsets.all(12.r), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + boxShadow: DesignConstants.shadowSm, + ), + child: QrImageView( + data: _upiUri, + version: QrVersions.auto, + // Fixed, and deliberately large: this is read by another phone's + // camera at arm's length, often in sunlight. Scaling it with the + // screen would make it marginal on the small handsets riders + // actually carry. + size: 190, + gapless: false, + eyeStyle: QrEyeStyle( + eyeShape: QrEyeShape.square, + color: ColorConstants.slateText, + ), + dataModuleStyle: QrDataModuleStyle( + dataModuleShape: QrDataModuleShape.square, + color: ColorConstants.slateText, + ), + ), + ), + SizedBox(height: 12.h), + Text( + '₹${_fmt(_due)} to $_payeeVpa', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w800, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 3.h), + Text( + 'Any UPI app · the amount is already filled in', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } + + Widget _methodTile(_Method m) { + final selected = _method == m; + return PressScale( + onTap: () => setState(() => _method = m), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h), + decoration: BoxDecoration( + color: selected + ? m.accent.withValues(alpha: 0.14) + : ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Row( + children: [ + Container( + padding: EdgeInsets.all(8.r), + decoration: BoxDecoration( + color: m.accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Icon(m.icon, size: 18.sp, color: m.accent), + ), + SizedBox(width: 11.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + m.label, + style: TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w800, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + Text( + m.blurb, + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + Icon( + selected + ? Icons.radio_button_checked_rounded + : Icons.radio_button_unchecked_rounded, + size: 20.sp, + color: selected ? m.accent : ColorConstants.borderStrong, + ), + ], + ), + ), + ); + } + + // ── Short payment ────────────────────────────────────────────────────── + // + // The rider cannot change what is owed, but he must be able to report what + // he actually took. Without this he has two bad options at a door where the + // customer is ₹50 short: claim the full amount and carry the gap himself, or + // abandon a stop he has already worked. + Widget _shortCard() { + return _card( + title: 'Did they pay the full amount?', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: _toggle( + label: 'Paid ₹${_fmt(_due)}', + selected: !_short, + accent: ColorConstants.acceptGreen, + onTap: () => setState(() => _short = false), + ), + ), + SizedBox(width: 9.w), + Expanded( + child: _toggle( + label: 'Paid less', + selected: _short, + accent: ColorConstants.warning, + onTap: () => setState(() => _short = true), + ), + ), + ], + ), + if (_short) ...[ + SizedBox(height: 12.h), + _field( + controller: _received, + hint: 'Amount you actually took', + icon: Icons.currency_rupee_rounded, + keyboard: const TextInputType.numberWithOptions(decimal: true), + formatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')), + ], + ), + SizedBox(height: 10.h), + _field( + controller: _shortReason, + hint: 'Why? e.g. customer had no change', + icon: Icons.notes_rounded, + maxLines: 2, + capitalization: TextCapitalization.sentences, + ), + if (_collected > 0 && _collected < _due) ...[ + SizedBox(height: 11.h), + _notice( + '₹${_fmt(_due - _collected)} still owed on this consignment', + ColorConstants.warning, + ), + ], + ], + ], + ), + ); + } + + // ── Custody ──────────────────────────────────────────────────────────── + // + // The whole reason this screen reads differently from a shop's checkout. + // The rider is holding somebody else's money, and the app should say so + // once, plainly, at the moment he takes it. + Widget _custodyNote() { + final onBehalf = _shipper.isNotEmpty ? _shipper : 'the shipper'; + return Container( + width: double.infinity, + padding: EdgeInsets.all(13.r), + decoration: BoxDecoration( + color: ColorConstants.tertiary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.account_balance_wallet_rounded, + size: 17.sp, + color: ColorConstants.tertiary, + ), + SizedBox(width: 10.w), + Expanded( + child: Text( + 'This money belongs to $onBehalf, not to you and not to Miler. ' + 'Deposit it at the hub at the end of your shift.', + style: TextStyle( + fontSize: 12.sp, + height: 1.4, + fontWeight: FontWeight.w700, + color: ColorConstants.tertiary, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } + + // ── Bottom bar ───────────────────────────────────────────────────────── + Widget _bottomBar() { + final hint = _blockingHint; + return Container( + padding: EdgeInsets.fromLTRB(14.w, 10.h, 14.w, 12.h), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + border: Border( + top: BorderSide(color: ColorConstants.borderSubtle, width: 1), + ), + boxShadow: DesignConstants.shadowSm, + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (hint != null) ...[ + Text( + hint, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 9.h), + ], + _slider(enabled: hint == null), + SizedBox(height: 8.h), + TextButton( + onPressed: _submitting ? null : _declinePayment, + child: Text( + 'Customer cannot pay', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.errorRed, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _slider({required bool enabled}) { + if (_submitting) { + return Container( + height: ButtonSizes.primary, + decoration: BoxDecoration( + color: ColorConstants.acceptGreen, + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + child: const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + ), + ); + } + + if (!enabled) { + return Container( + height: ButtonSizes.primary, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.neutralLight, + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + child: Text( + 'Finish the details above', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ); + } + + return SliderButton( + properties: SliderButtonProperties( + height: ButtonSizes.primary, + buttonSize: 48, + width: MediaQuery.of(context).size.width - 28.w, + backgroundColor: _method.accent, + dismissThresholds: 0.85, + action: () async { + _finish(paid: true); + return true; + }, + label: Text( + 'Slide — took ₹${_fmt(_collected)} by ${_method.label}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w800, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + icon: Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + shape: BoxShape.circle, + ), + child: Icon(Icons.check_rounded, size: 22.sp, color: _method.accent), + ), + ), + ); + } + + // ── Nothing owed ─────────────────────────────────────────────────────── + Widget _nothingToCollect() { + return Scaffold( + backgroundColor: ColorConstants.pureSurface, + appBar: AppBar( + backgroundColor: ColorConstants.pureSurface, + surfaceTintColor: Colors.transparent, + elevation: 0, + title: Text( + 'Collect payment', + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w800, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + body: Center( + child: Padding( + padding: EdgeInsets.all(28.r), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle_rounded, + size: 54.sp, + color: ColorConstants.acceptGreen, + ), + SizedBox(height: 14.h), + Text( + 'Nothing to collect here', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w800, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 6.h), + Text( + 'This consignment is prepaid. Carry on to the next stop.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13.sp, + height: 1.4, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 20.h), + MilerButton( + label: 'Continue', + icon: Icons.arrow_forward_rounded, + color: ColorConstants.acceptGreen, + onPressed: () => Navigator.pop(context, { + 'paid': true, + 'verified': true, + 'amountDue': 0.0, + 'amountCollected': 0.0, + }), + ), + ], + ), + ), + ), + ); + } + + // ── Shared pieces ────────────────────────────────────────────────────── + + Widget _card({required String title, required Widget child}) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + color: ColorConstants.cardSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 12.h), + child, + ], + ), + ); + } + + Widget _toggle({ + required String label, + required bool selected, + required Color accent, + required VoidCallback onTap, + }) { + return PressScale( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 8.w), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? accent : ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w800, + color: selected ? Colors.white : ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ); + } + + Widget _field({ + required TextEditingController controller, + required String hint, + required IconData icon, + int maxLines = 1, + TextInputType? keyboard, + List? formatters, + TextCapitalization capitalization = TextCapitalization.none, + }) { + return TextField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboard, + inputFormatters: formatters, + textCapitalization: capitalization, + onChanged: (_) => setState(() {}), + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + prefixIcon: Icon( + icon, + size: 18.sp, + color: ColorConstants.secondaryText, + ), + filled: true, + fillColor: ColorConstants.pureSurface, + contentPadding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 13.h), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide(color: ColorConstants.primary, width: 1.6), + ), + ), + ); + } + + Widget _notice(String text, Color accent) { + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Row( + children: [ + Icon(Icons.info_rounded, size: 16.sp, color: accent), + SizedBox(width: 8.w), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 12.sp, + height: 1.35, + fontWeight: FontWeight.w700, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } +} + +/// Small confirmation sheet for the one destructive choice on this page. +class _ConfirmSheet extends StatelessWidget { + final String title; + final String body; + final String confirmLabel; + + const _ConfirmSheet({ + required this.title, + required this.body, + required this.confirmLabel, + }); + + @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), + ), + ), + 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/done.dart b/lib/views/Dashboard/pickups/done.dart new file mode 100644 index 0000000..b2349b6 --- /dev/null +++ b/lib/views/Dashboard/pickups/done.dart @@ -0,0 +1,979 @@ +part of 'pickups.dart'; + +// ───────────────────────────────────────────────────────────────────────────── +// PICKUP DONE SCREEN — PREMIUM REDESIGN (Scratch Card + Standard) +// ───────────────────────────────────────────────────────────────────────────── +class PickupsDone extends StatefulWidget { + final bool isCancelled; + final bool isDelivery; + final int bonusPoints; + final List>? remainingOrders; + final String? paymentMethod; + final _MyPickupsState? parentState; + + const PickupsDone({ + super.key, + this.isCancelled = false, + this.isDelivery = false, + this.bonusPoints = 0, + this.remainingOrders, + this.paymentMethod, + this.parentState, + }); + + @override + State createState() => _PickupsDoneState(); +} + +class _PickupsDoneState extends State + with TickerProviderStateMixin { + late ConfettiController _confettiController; + final GlobalKey _scratcherKey = GlobalKey(); + double _opacity = 0.0; + bool _isScratched = false; + + late AnimationController _entranceController; + late Animation _circleScaleAnim; + late Animation _fadeAnim; + late Animation _slideAnim; + + /// The next few stops, capped for display. + List> _nextOrders = []; + + /// How many stops are actually left, before the display cap. The screen + /// tells the rider where he is in the day, and "3 left" when 11 remain is + /// worse than saying nothing. + int _remainingCount = 0; + bool _loadingNextOrders = true; + + @override + void initState() { + super.initState(); + _confettiController = ConfettiController( + duration: const Duration(seconds: 4), + ); + + // 800 → 300. This screen sits between two stops and the rider crosses it + // 40 times a shift; an eight-hundred-millisecond entrance is half a second + // of standing still, 40 times. The celebration branch keeps its own longer + // beat — see [DesignConstants.motionCelebrate] — because that fires once + // per completed route, not once per stop. + _entranceController = AnimationController( + duration: DesignConstants.motionPage, + vsync: this, + ); + + _circleScaleAnim = CurvedAnimation( + parent: _entranceController, + curve: const Interval(0.0, 0.6, curve: Curves.elasticOut), + ); + _fadeAnim = CurvedAnimation( + parent: _entranceController, + curve: const Interval(0.3, 1.0, curve: Curves.easeOut), + ); + _slideAnim = Tween(begin: const Offset(0, 0.3), end: Offset.zero) + .animate( + CurvedAnimation( + parent: _entranceController, + curve: const Interval(0.3, 1.0, curve: Curves.easeOut), + ), + ); + + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted) { + _entranceController.forward(); + // Celebrate only when it's actually special: a real bonus reveal. + // Route-completion confetti fires from _loadNextOrders once we know no + // stops remain. A plain mid-route stop gets a calm screen (no confetti) + // so the rider isn't slowed by fireworks 15 times a shift. + if (!widget.isCancelled && widget.bonusPoints > 0) { + _confettiController.play(); + } + } + }); + + _loadNextOrders(); + } + + /// Fires the confetti only when the route is finished (no stops left). A + /// bonus reveal already celebrates in initState, and cancelled stops never do. + void _maybeCelebrateRouteComplete() { + if (!mounted || widget.isCancelled || widget.bonusPoints > 0) return; + if (_nextOrders.isEmpty) _confettiController.play(); + } + + Future _loadNextOrders() async { + try { + // If parent passed remaining bookings, use those + if (widget.remainingOrders != null) { + final live = widget.remainingOrders! + .where((o) => !stopStatusOf(o).isFinishedPickup) + .toList(); + setState(() { + _remainingCount = live.length; + _nextOrders = live.take(3).toList(); + _loadingNextOrders = false; + }); + _maybeCelebrateRouteComplete(); + return; + } + + // Otherwise fetch from SharedPreferences / API + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0; + + if (userId == 0) { + if (mounted) setState(() => _loadingNextOrders = false); + return; + } + + final provider = PickupProvider(); + final items = await provider.getPickupQueuesPicked(); + + List> next = items + .whereType>() + .toList(); + + // Fallback when the server returns nothing. On the live backend the + // accepted store is the load-bearing record of bookings the rider has + // taken on (the backend keeps them as 'miler_assigned'), so use it to + // surface the remaining stops — completed/cancelled ones are removed from + // the store on finish, so they won't show here. The mock list is only + // added on the old demo path. + if (next.isEmpty) { + final accepted = await getAcceptedBookings(); + next = [...accepted]; + } + + // Nothing left is a real answer here — it is what "Return to hub" is for. + // The debug demo-route fallback that used to sit here is gone with the + // rest of the mock layer. + + // Keep only pickups that aren't already completed/cancelled. + next = next.where((o) => !stopStatusOf(o).isFinishedPickup).toList(); + + if (mounted) { + setState(() { + _remainingCount = next.length; + _nextOrders = next.take(3).toList(); + _loadingNextOrders = false; + }); + _maybeCelebrateRouteComplete(); + } + } catch (e) { + debugPrint('[DONE] Error loading next bookings: $e'); + if (mounted) setState(() => _loadingNextOrders = false); + } + } + + @override + void dispose() { + _confettiController.dispose(); + _entranceController.dispose(); + super.dispose(); + } + + /// Opens the next pickup stop's navigation screen, replacing this completion + /// screen so the rider can't tap back into it. When no stops remain, returns + /// to the hub (Bookings tab). + void _goToNextStop({Map? order}) { + final Map? next = + order ?? (_nextOrders.isNotEmpty ? _nextOrders.first : null); + if (next != null) { + // Use the same screen as "Start pickup" so every stop has an identical + // flow: preview → navigate → arrive → verify → confirm → next stop. + // Thread the real pickups state through so each stop's completion updates + // the one live list (removes it, unblocks the following stop). + Get.off( + () => _PickupMapScreen(pickup: next, parentState: widget.parentState), + ); + } else { + Get.offAll(() => const BottomPage(initialIndex: 1)); + } + } + + /// The screen's single surface. With nothing on the page boxed, there is + /// only one background, and everything — including the pinned CTA bar — is + /// drawn straight onto it. + static Color get _pageBg => ColorConstants.daylightSurface; + + @override + Widget build(BuildContext context) { + final bool showScratchCard = !widget.isCancelled && widget.bonusPoints > 0; + + return Scaffold( + backgroundColor: _pageBg, + body: Stack( + children: [ + SafeArea( + child: showScratchCard + ? _buildScratchCardContent() + : _buildStandardContent(), + ), + // Confetti overlay + Align( + alignment: Alignment.topCenter, + child: ConfettiWidget( + confettiController: _confettiController, + blastDirectionality: BlastDirectionality.explosive, + shouldLoop: false, + numberOfParticles: 30, + gravity: 0.3, + emissionFrequency: 0.05, + colors: [ + ColorConstants.primary, + ColorConstants.acceptGreen, + Color(0xFF007AFF), + Color(0xFFFFB800), + Color(0xFFAF52DE), + ], + createParticlePath: drawStar, + ), + ), + ], + ), + ); + } + + // ── Standard Picked up / Cancelled ───────────────────────────────────────── + // + // A handoff, not a trophy — and one surface, not five. + // + // ── What was wrong with the previous version ── + // + // Every group on this screen was a bordered, filled, rounded rectangle: a + // green banner, a shadowed hero card, a bordered list card with a pill inside + // it and a filled purple badge inside that. Four containers, three border + // weights and a shadow, stacked down a page holding about forty words. + // + // The cost is not ugliness, it is *ranking*. A border says "this is a thing, + // separate from its neighbours". Give that emphasis to all four groups and + // none of them is emphasised: the rider's eye lands on whichever box is + // biggest rather than on whichever fact matters, and the thing he opened the + // screen for — where am I going next — has to be found rather than seen. + // + // ── What replaces them ── + // + // Nothing is boxed. Rank is carried by the things that carry rank for free: + // + // • **Type size.** The stop name is 28sp against a 12.5sp status line. That + // ratio alone answers "what is this screen about" before anything is read. + // • **Colour weight.** Finished work is grey with a small green tick; the + // live destination is near-black. Completed information should be legible + // and ignorable, which is exactly what grey-on-neutral is. + // • **Whitespace.** A 26pt gap groups more cleanly than a 1px border does, + // and it costs no visual noise to do it. + // • **One hairline**, between the hero and the list — the single place where + // a genuine change of subject happens. + // + // The order is the rider's own sentence: you finished that → this is next → + // then these → press this. Roughly one second, top to bottom, without a + // single edge to parse. + Widget _buildStandardContent() { + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + // ── The top margin is not decoration ── + // + // This screen has no app bar — there is nowhere to go back to, so + // there is nothing for one to hold — and `SafeArea` only clears the + // notch. With 10pt after it the status line began about 34pt down + // an 844pt page, hard under the clock, and every page in the app + // that *does* have a bar starts its content around 90pt. So the + // handoff read as the one screen missing its header rather than as + // the one screen that does not need one. + // + // 34pt puts the first line clear of the system chrome and gives the + // page a top edge of its own. It is the same trick Uber's post-trip + // screens use: no bar, but a margin deep enough that the absence + // reads as deliberate. + padding: EdgeInsets.fromLTRB(20.w, 34.h, 20.w, 32.h), + child: FadeTransition( + opacity: _fadeAnim, + child: SlideTransition( + position: _slideAnim, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _statusLine(), + if (_loadingNextOrders) + _findingNextStop() + else if (_nextOrders.isEmpty) + _routeComplete() + else ...[ + // Deliberately wider than the 34pt margin above the + // status line. Equal gaps left that line belonging to + // neither the top edge nor the hero — it just hung there. + // Tighter above than below groups it with the top of the + // page, which is what it is: a footnote on the screen the + // rider just left, not an introduction to this one. + SizedBox(height: 42.h), + _nextStopHero(_nextOrders.first), + if (_nextOrders.length > 1) _upNext(), + ], + ], + ), + ), + ), + ), + ), + _handoffBar(), + ], + ); + } + + /// What just happened, and how much is left — on one grey line. + /// + /// This was a filled, bordered green banner with a bordered white pill inside + /// it, about 60pt tall, and it was the loudest object on a screen whose + /// subject is somewhere else entirely. It is *completed* information: the + /// rider performed the act himself thirty seconds ago and is not deciding + /// anything with it. He needs to be able to confirm it, not to be told it. + /// + /// So the green survives only in the tick — enough to answer "did that + /// register?" at a glance — and the words drop to the same grey as every + /// other secondary caption in the app. The count rides the same line, dot + /// separated, because "delivered" and "9 left" are one thought about the + /// shift, not two facts needing two containers. + Widget _statusLine() { + final isCancelled = widget.isCancelled; + final method = widget.paymentMethod?.trim() ?? ''; + final done = widget.isDelivery ? 'Delivered' : 'Picked up'; + + final parts = [ + isCancelled ? 'Not completed' : done, + if (!isCancelled && method.isNotEmpty) method, + if (_remainingCount > 0) + '$_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, + ), + 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, + ), + ), + ), + ], + ); + } + + /// The next stop, at the size of the decision it is. + /// + /// No card, no border, no shadow. A hero that needs a box drawn round it to + /// read as the hero is not yet a hero — and this one does not: at 28sp + /// against a 12.5sp status line above it and 15sp rows below, it is the only + /// thing on the screen the eye can land on first. + /// + /// The stop type moves from a filled purple badge floated to the right into a + /// dotted label under the address. Same information, ranked correctly: the + /// rider decides *whether to go* from the name and the address, and only then + /// 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); + final name = (stop['pickupcustomer'] ?? 'Stop').toString(); + final address = (stop['pickupaddress'] ?? 'Address not available') + .toString(); + final kms = (stop['kms'] ?? '').toString(); + final skipped = + (stop['orderstatus']?.toString().toLowerCase() ?? '') == 'skipped'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('NEXT STOP', style: _eyebrow), + SizedBox(height: 10.h), + Text( + name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 28.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.9, + height: 1.08, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 10.h), + Text( + address, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.5.sp, + height: 1.45, + fontWeight: FontWeight.w500, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 16.h), + Row( + children: [ + // The one place the type colour appears, and it appears as a 6pt + // dot. Enough to separate a delivery from a pickup at a glance + // across a day of these; not enough to compete with the name. + Container( + width: 6.w, + height: 6.w, + decoration: BoxDecoration( + color: kind.accent, + shape: BoxShape.circle, + ), + ), + SizedBox(width: 8.w), + Flexible( + child: Text( + kind.titleLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w700, + color: kind.accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + if (kms.isNotEmpty && kms != '0') ...[ + Text(' · ', style: _metaStyle), + Text('$kms km away', style: _metaStyle), + ], + ], + ), + if (skipped) ...[SizedBox(height: 14.h), _retryNote()], + ], + ); + } + + /// The one place on this screen that still earns a fill. + /// + /// It is not a group of related content — it is an exception, and an + /// exception that contradicts what the rider remembers: he passed this stop + /// earlier and is being sent back. A tinted strip is how the app marks that + /// everywhere else, and dropping it to a plain line here would hide the one + /// fact on the screen he cannot infer from anything around it. + Widget _retryNote() { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.replay_rounded, + size: 15.sp, + color: ColorConstants.warning, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + 'You skipped this one earlier — this is the retry', + style: TextStyle( + fontSize: 12.5.sp, + height: 1.35, + fontWeight: FontWeight.w700, + color: ColorConstants.warning, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ); + } + + /// Stops after the next one: one list, not a stack of cards. + /// + /// These are context, never a decision — the rider only ever acts on the stop + /// in front of him. So they get the quietest treatment that is still tappable: + /// a numbered circle, two lines of text, a chevron, and a hairline between + /// rows inset to the text so the column reads as one list rather than as + /// separate objects. + /// + /// The single full-width rule above the section is the only border on the + /// screen. It is there because this is the one point where the subject + /// genuinely changes — from the stop he is going to, to the ones after it. + Widget _upNext() { + final later = _nextOrders.skip(1).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 32.h), + Container(height: 1, color: ColorConstants.borderSubtle), + SizedBox(height: 22.h), + Text( + 'UP NEXT · ${later.length} ${later.length == 1 ? 'STOP' : 'STOPS'}', + style: _eyebrow, + ), + SizedBox(height: 6.h), + for (var i = 0; i < later.length; i++) ...[ + if (i > 0) + Padding( + // Inset to the text column, not to the numbers. A rule that runs + // under the circles cuts the list into slabs; one that starts + // where the names start reads as a separator inside a single list. + padding: EdgeInsets.only(left: 39.w), + child: Container(height: 1, color: ColorConstants.borderSubtle), + ), + _upNextRow(later[i], i + 2), + ], + ], + ); + } + + Widget _upNextRow(Map stop, int position) { + final kind = stopKindOf(stop); + final name = (stop['pickupcustomer'] ?? 'Stop').toString(); + final address = (stop['pickupaddress'] ?? '').toString(); + + return InkWell( + onTap: () => _goToNextStop(order: stop), + child: Padding( + padding: EdgeInsets.symmetric(vertical: 15.h), + child: Row( + children: [ + Container( + width: 26.w, + height: 26.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: kind.accent.withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Text( + '$position', + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w800, + color: kind.accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 13.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + if (address.isNotEmpty) ...[ + SizedBox(height: 2.h), + Text( + address, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ], + ), + ), + SizedBox(width: 8.w), + Icon( + Icons.chevron_right_rounded, + size: 20.sp, + color: ColorConstants.secondaryText, + ), + ], + ), + ), + ); + } + + Widget _findingNextStop() { + return Padding( + padding: EdgeInsets.symmetric(vertical: 78.h), + child: Center( + child: Column( + children: [ + SizedBox( + width: 22.w, + height: 22.w, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: AlwaysStoppedAnimation( + ColorConstants.primary, + ), + ), + ), + SizedBox(height: 14.h), + Text( + 'Finding your next stop…', + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ); + } + + /// The one moment on this screen that has earned a celebration: the route is + /// finished. Confetti already fires from [_maybeCelebrateRouteComplete]. + /// + /// Still no card. The green disc is a *mark* — it is the subject of the + /// message, not a container holding it — which is the line this screen draws + /// between a shape that is allowed and one that is not. + Widget _routeComplete() { + return Padding( + padding: EdgeInsets.fromLTRB(8.w, 58.h, 8.w, 24.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + color: ColorConstants.acceptGreen.withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Icon( + Icons.warehouse_rounded, + size: 30.sp, + color: ColorConstants.acceptGreen, + ), + ), + SizedBox(height: 18.h), + Text( + 'Every stop done', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.6, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 8.h), + Text( + 'Head back to the hub to drop the parcels and hand in any cash ' + 'you collected.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.sp, + height: 1.5, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } + + /// Pinned CTA. + /// + /// Same background as the page and a single hairline above it, rather than a + /// white slab with a shadow. The bar is not a separate surface floating over + /// the content — it is the bottom of the page with the button held in it, and + /// on a screen that has just given up every other border it would be the + /// heaviest edge left if it kept its own. + Widget _handoffBar() { + final hasNext = _nextOrders.isNotEmpty; + return Container( + padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 12.h), + decoration: BoxDecoration( + color: _pageBg, + border: Border( + top: BorderSide(color: ColorConstants.borderSubtle, width: 1), + ), + ), + child: SafeArea( + top: false, + child: MilerButton( + label: _loadingNextOrders + ? 'Loading…' + : hasNext + ? 'Move to next stop' + : 'Return to hub', + icon: hasNext ? Icons.arrow_forward_rounded : Icons.warehouse_rounded, + color: hasNext ? ColorConstants.acceptGreen : ColorConstants.primary, + loading: _loadingNextOrders, + onPressed: _loadingNextOrders ? null : () => _goToNextStop(), + ), + ), + ); + } + + /// 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, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ); + + TextStyle get _metaStyle => TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ); + + // ── Scratch Card Content ─────────────────────────────────────────────────── + Widget _buildScratchCardContent() { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 24.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Spacer(flex: 1), + + ScaleTransition( + scale: _circleScaleAnim, + child: Container( + width: 68.w, + height: 68.w, + // ── Celebration, in the app's own voice ── + // + // This was an amber gradient over a 20px coloured glow, in two + // hex values that appear nowhere else in the product — so the one + // moment a rider is being congratulated looked like it came from + // a different app. It stays visually special (it is the only + // filled disc on any handoff screen, and it still scales in with + // an elastic curve) but it is now the app's own reward colour, + // flat, the way every other surface here is. + decoration: BoxDecoration( + color: ColorConstants.rewardAmber, + shape: BoxShape.circle, + ), + child: Icon( + Icons.card_giftcard_rounded, + size: 30.sp, + color: Colors.white, + ), + ), + ), + + SizedBox(height: 16.h), + FadeTransition( + opacity: _fadeAnim, + child: Column( + children: [ + Text( + 'You Won a Bonus!', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.w900, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 6.h), + Text( + 'Scratch the card to reveal your bonus points', + style: TextStyle( + fontSize: 14.sp, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + + SizedBox(height: 24.h), + + // Scratch Card + FadeTransition( + opacity: _fadeAnim, + child: Center( + child: Container( + width: 250.w, + height: 250.w, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 20, + offset: const Offset(0, 8), + ), + BoxShadow( + color: const Color(0xFFFFB800).withValues(alpha: 0.15), + blurRadius: 30, + offset: const Offset(0, 12), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: Scratcher( + key: _scratcherKey, + brushSize: 50, + threshold: 50, + color: ColorConstants.primary, + onChange: (value) {}, + onThreshold: () { + _confettiController.play(); + setState(() { + _opacity = 1.0; + _isScratched = true; + }); + }, + image: Image.asset( + 'assets/images/doormile_icon.png', + fit: BoxFit.scaleDown, + width: 100, + height: 100, + ), + child: Container( + width: 250, + height: 250, + color: ColorConstants.pureSurface, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.monetization_on_rounded, + size: 64.sp, + color: const Color(0xFFFFB800), + ), + SizedBox(height: 10.h), + Text( + '${widget.bonusPoints}', + style: TextStyle( + fontSize: 52.sp, + fontWeight: FontWeight.w900, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + letterSpacing: -2, + ), + ), + Text( + 'Bonus Points', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + + if (!_isScratched) + Padding( + padding: EdgeInsets.only(top: 16.h), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.touch_app_rounded, + color: ColorConstants.onSurfaceVariant, + size: 16.sp, + ), + SizedBox(width: 6.w), + Text( + 'Scratch the card to reveal', + style: TextStyle( + fontSize: 13.sp, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + + const Spacer(flex: 1), + _buildDoneButton(isEnabled: _isScratched), + SizedBox(height: 24.h), + ], + ), + ); + } + + /// ── One button, not a second implementation of one ── + /// + /// This was a hand-rolled `AnimatedContainer` with a gradient fill, a drop + /// shadow, its own radius, its own label size and `disabledFill` used as the + /// *text* colour when disabled. It is the same action as the button on the + /// no-bonus branch fifty lines up — "move to the next stop" — so it is the + /// same button now, and the celebration is carried by the screen around it + /// rather than by giving the CTA its own costume. + 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, + color: hasNext ? ColorConstants.acceptGreen : ColorConstants.primary, + onPressed: isEnabled ? () => _goToNextStop() : null, + ); + } + + Path drawStar(Size size) { + 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/views/Dashboard/pickups/map.dart b/lib/views/Dashboard/pickups/map.dart new file mode 100644 index 0000000..2a457f3 --- /dev/null +++ b/lib/views/Dashboard/pickups/map.dart @@ -0,0 +1,1587 @@ +part of 'pickups.dart'; + +// ------------------------------------------------------------------------- +// SCREEN 1: PICKUP MAP PREVIEW — CORPORATE REDESIGN +// ------------------------------------------------------------------------- +class _PickupMapScreen extends StatefulWidget { + final Map pickup; + final _MyPickupsState? parentState; + const _PickupMapScreen({required this.pickup, this.parentState}); + + @override + State<_PickupMapScreen> createState() => _PickupMapScreenState(); +} + +class _PickupMapScreenState extends State<_PickupMapScreen> + with TickerProviderStateMixin, WidgetsBindingObserver { + final MapController _mapController = MapController(); + + /// Eased camera moves. `flutter_map`'s controller only teleports; the route + /// choreography here depends on the camera landing before the line draws. + late final MilerMapCamera _camera; + late LatLng _pickupLocation, _dropLocation; + + /// The ends of the line currently drawn. See [_routeOrigin]. + late LatLng _routeStart, _routeEnd; + + /// True once the drawn route starts from the rider rather than from the stop. + bool _routedFromRider = false; + final List _markers = []; + + // ── Four named lines, not a set keyed by id ── + // + // Google Maps took a `Set` and sorted it by `zIndex`, so the layers + // were addressed by string ids — 'route_casing', 'route_pulse_glow' — and + // every frame did a `removeWhere` over the set to replace one of them. + // `flutter_map` draws a list in order, so the order *is* the z-order and the + // ids have nothing left to do. Naming the four layers says what the screen is + // made of, and swapping one is an assignment rather than a search. + Polyline? _casingLine; // the whole route, faint underneath + Polyline? _progressLine; // the bright line, drawing itself in + Polyline? _flowHalo; // the flow's soft halo + Polyline? _flowCore; // the flow's bright core + + List get _routeLayers => [ + if (_casingLine != null) _casingLine!, + if (_progressLine != null) _progressLine!, + if (_flowHalo != null) _flowHalo!, + if (_flowCore != null) _flowCore!, + ]; + + /// Bumped whenever a route layer changes during an animation, so that only the + /// map is rebuilt. + /// + /// Calling `setState` for this instead rebuilt the entire screen sixty times a + /// second — the header, the draggable sheet and every row inside it — to move + /// one line on the map. On a mid-range phone that alone missed frames, and a + /// travelling highlight that misses frames is exactly what "stuck" looks like. + final ValueNotifier _mapRepaint = ValueNotifier(0); + bool _isLoadingRoute = true; + bool _isNavigating = false; + bool _handlingArrival = false; + + /// Clears the in-flight flag **and rebuilds**. + /// + /// `_buildSwipeButton` renders a spinner in place of Navigate / I've arrived + /// while this is true, and every exit from `_startPickupNavigation` except + /// the happy one used to clear it with a bare assignment. No rebuild followed, + /// so the map sat there spinning with no buttons — a screen that looks hung + /// is indistinguishable from a confirm that did nothing. + void _endNavigating() { + if (!mounted) { + _isNavigating = false; + return; + } + setState(() => _isNavigating = false); + } + + bool _hasOpenedNavigation = false; + + // Uber-style route reveal: the full route sits as a faint "casing" and the + // bright line draws itself in from origin to destination. + List _routePoints = []; + + /// Distance from the route's start to each point, in the same arbitrary unit. + /// + /// Both animations are parameterised by *distance* along this table rather + /// than by point index, and that is the whole difference between the effect + /// working and not. A routing API returns vertices where the road bends: a + /// tight corner can carry twenty points across thirty metres while a highway + /// straight carries two across a kilometre. Stepping by index therefore made + /// the head crawl through every junction and leap down every straight — which + /// looks like stuttering, because it is. + List _cumulative = []; + late final AnimationController _routeAnim; + + /// ── The route flow ── + /// + /// The draw-in happens once and is over. This is what keeps the line reading + /// as a *direction of travel* rather than as a static shape. + /// + /// It used to be a comet: an 18%-of-the-route window sliding from one end to + /// the other. At any moment the rider saw a short white dash a few hundred + /// metres long somewhere in the middle of his journey, and a dash moving over + /// a line reads as a decoration attached to the map rather than as anything + /// about his trip. On a long route it was worse — 18% of 9 km is a smear that + /// never visibly connects the two ends. + /// + /// Now it is one continuous stream: white grows from the rider's own position, + /// follows every bend of the real geometry to the stop, holds for a beat, and + /// then fades from the destination back down the line to him. That is the + /// shape of the fact the animation is stating — *this route, from you, to + /// there* — and it is what Uber's own route flow does. + late final AnimationController _flowAnim; + + /// Where the cycle changes phase, as fractions of [_flowAnim]. + /// + /// Recomputed with the controller's duration whenever a route arrives, since + /// the grow phase is timed by distance (see [_restartFlow]) while the hold and + /// the fade are fixed. + double _growShare = 0.68; + double _holdShare = 0.06; + + // ── Live distance / ETA ── + // The rider is moving while this screen is open, so a distance captured on + // entry is stale within a block. A position stream keeps the strip honest; + // `distanceFilter` throttles it to every 15 m so it isn't a per-fix rebuild + // storm on a phone that is also running Google Maps. + StreamSubscription? _positionSub; + Position? _livePosition; + + // Corporate Red Theme Colors + static Color get kPrimary => ColorConstants.primary; + + /// The halo around the flowing stroke. + /// + /// A dark white, painted wider than the core and underneath it, so the bright + /// stroke has something to sit in and reads as light falling on the road + /// rather than as a white sticker laid along it. + /// + /// Kept warm rather than neutral grey: the route underneath is red, and a cool + /// grey over it turns muddy at the alpha the halo runs at. + static const Color _flowHaloColour = Color(0xFFA89E9E); + + /// 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. + static const double _flowMetresPerSecond = 1900; + + /// Grow-phase bounds. See [_restartFlow] for why the speed is what is held + /// constant and these are only the extremes. + /// + /// Both moved down with the speed. Leaving the floor where it was would have + /// meant every short leg — the ones a first-mile rider actually runs — pinned + /// to the clamp and therefore unchanged, which is where the line read slow in + /// the first place. + static const double _flowGrowMinMs = 1000; + static const double _flowGrowMaxMs = 3300; + + /// The beat at the destination before the line drains. + static const int _flowHoldMs = 220; + + /// How long the stream takes to drain back to the rider. Kept a shade under + /// the grow floor: a drain slower than the stroke that produced it makes the + /// whole cycle feel like it is sagging at the end. + static const int _flowFadeMs = 700; + + /// 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; + + static const double _flowHaloAlpha = 0.42; + static Color get kBackground => ColorConstants.surfaceContainerLow; + static Color get kTextPrimary => ColorConstants.onSurface; + static const Color kCardBg = Colors.white; + + @override + void initState() { + super.initState(); + _camera = MilerMapCamera(controller: _mapController, vsync: this); + _routeAnim = + AnimationController( + vsync: this, + // 1400 → 1800. The line covers the whole journey, and at 1.4s over a 7km + // route it read as a wipe rather than as something travelling. + duration: const Duration(milliseconds: 1800), + ) + ..addListener(_onRouteAnimTick) + // The pulse only starts once the route has finished drawing — running + // both at once would put two heads on the same line. + ..addStatusListener((status) { + if (status == AnimationStatus.completed && mounted) { + _restartFlow(); + } + }); + // Duration is set per route — see [_restartFlow]. + _flowAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2400), + )..addListener(_onFlowTick); + WidgetsBinding.instance.addObserver(this); + _resolveLocations(); + _setMarkers(); + _createRealRoute(); + _startLiveTracking(); + _stampDeparture(); + // NOTE: We intentionally do NOT auto-launch Google Maps here. "Start pickup" + // opens this preview (map + customer + Navigate / I've arrived). Google Maps + // only opens when the rider explicitly taps "Navigate". + } + + /// Records the moment the rider set off for this stop. + /// + /// Opening this screen *is* setting off: it is what "Start pickup" leads to, + /// and from here on he is riding. Paired with the arrival stamp written when + /// he confirms he is at the door, it is what lets Activity answer "how long + /// did that stop take me" — a figure the app was measuring nowhere despite + /// billing per kilometre on the same journey. + /// + /// Written once. A rider who backs out to check the list and comes back is on + /// the same errand, and re-stamping would quietly erase the ride he has + /// already done. + Future _stampDeparture() async { + final pickupId = (widget.pickup['pickupid'] ?? '').toString(); + if (pickupId.isEmpty) return; + try { + final prefs = await SharedPreferences.getInstance(); + final key = kStopStartedKey(pickupId); + if (prefs.getString(key) == null) { + await prefs.setString(key, DateTime.now().toIso8601String()); + } + } catch (e) { + debugPrint('[MAP] Could not stamp departure: $e'); + } + } + + /// Launches Google Maps turn-by-turn navigation to the pickup location. + /// Tries the native navigation intent first, then falls back to the web URL. + Future _openGoogleMapsNavigation() async { + if (_hasOpenedNavigation) return true; + try { + double? originLat, originLng; + try { + final pos = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.medium, + timeLimit: Duration(seconds: 3), + ), + ).timeout(const Duration(seconds: 3)); + originLat = pos.latitude; + originLng = pos.longitude; + } catch (_) {} + + final destLat = _pickupLocation.latitude; + final destLng = _pickupLocation.longitude; + + final nativeUri = Uri.parse( + 'google.navigation:q=$destLat,$destLng&mode=d', + ); + final originParam = (originLat != null && originLng != null) + ? '&origin=$originLat,$originLng' + : ''; + final webUri = Uri.parse( + 'https://www.google.com/maps/dir/?api=1$originParam' + '&destination=$destLat,$destLng&travelmode=driving&dir_action=navigate', + ); + + bool launched = false; + try { + launched = await launchUrl( + nativeUri, + mode: LaunchMode.externalApplication, + ); + } catch (e) { + debugPrint('[MAP_NAV] Native intent failed: $e'); + } + if (!launched) { + try { + launched = await launchUrl( + webUri, + mode: LaunchMode.externalApplication, + ); + } catch (e) { + debugPrint('[MAP_NAV] Web intent failed: $e'); + } + } + if (launched && mounted) { + setState(() => _hasOpenedNavigation = true); + return true; + } + } catch (e) { + debugPrint('[MAP_NAV] Error opening navigation: $e'); + } + return false; + } + + /// Follows the rider while the stop screen is open so the distance and ETA + /// count down as he rides. Best-effort: if permission or GPS is unavailable + /// the strip falls back to the last known booking coordinates rather than + /// disappearing. + void _startLiveTracking() { + try { + _positionSub = + Geolocator.getPositionStream( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: 15, + ), + ).listen( + (pos) { + if (!mounted) return; + setState(() => _livePosition = pos); + // The first fix is what turns "somewhere near the stop" into a + // route the rider can actually ride — see [_routeOrigin]. Drawn + // once, not on every fix: re-routing every 15 m would burn + // Directions quota and make the line twitch under his thumb. + if (!_routedFromRider) _createRealRoute(); + }, + onError: (e) => debugPrint('[MAP_LIVE] Position stream error: $e'), + ); + } catch (e) { + debugPrint('[MAP_LIVE] Could not start position stream: $e'); + } + } + + /// Where the drawn route starts. + /// + /// ── Why this is the rider and not the pickup ── + /// + /// The line used to run pickup → drop: the *parcel's* journey, from the + /// customer's door to wherever it is going afterwards. That is a fact about + /// the booking, not about the next twenty minutes of the rider's life — and + /// it contradicted everything else on the screen, because the ETA, the + /// distance and the Navigate button were all measured rider → stop. He was + /// shown "8 min · 2.4 km" over a line that went somewhere else entirely, and + /// on a stop where the drop was miles away the camera framed a route that did + /// not contain him. + /// + /// Falls back to the pickup location when there is no fix yet, so the first + /// frame still draws something honest; the first real fix re-routes. + LatLng? get _routeOrigin { + final live = _livePosition; + if (live != null) return LatLng(live.latitude, live.longitude); + final lat = _parseD(widget.pickup['riderslat']); + final lng = _parseD(widget.pickup['riderslon']); + if (lat != 0 && lng != 0) return LatLng(lat, lng); + return null; + } + + /// Metres from the rider to this stop, live where possible. + double? get _liveMetersToStop { + final lat = _livePosition?.latitude; + final lng = _livePosition?.longitude; + if (lat != null && lng != null) { + return RouteMetricsHelper.distanceMeters( + lat, + lng, + _pickupLocation.latitude, + _pickupLocation.longitude, + ); + } + return RouteMetricsHelper.metersToStop(widget.pickup); + } + + @override + void dispose() { + _positionSub?.cancel(); + WidgetsBinding.instance.removeObserver(this); + _routeAnim.dispose(); + _flowAnim.dispose(); + _mapRepaint.dispose(); + _camera.dispose(); + _mapController.dispose(); + super.dispose(); + } + + void _resolveLocations() { + final d = widget.pickup; + 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['PickupyLat'], + ); + final double dropLon = _parseD( + d['droplon'] ?? d['DropLon'] ?? d['PickupyLong'], + ); + 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; + + _pickupLocation = hasPickup + ? LatLng(pickLat, pickLon) + : (riderLat != 0 && riderLon != 0 + ? LatLng(riderLat, riderLon) + : const LatLng(10.998356, 76.977596)); + + _dropLocation = hasDrop + ? LatLng(dropLat, dropLon) + : const LatLng(11.004556, 76.967696); + + // Sensible ends before the first route attempt has decided them, so nothing + // downstream can read them uninitialised. + _routeStart = _pickupLocation; + _routeEnd = _dropLocation; + } + + double _parseD(dynamic v) { + if (v == null) return 0.0; + if (v is num) return v.toDouble(); + return double.tryParse(v.toString()) ?? 0.0; + } + + /// Two plain pins: where the rider is going, and where the parcel ends up. + /// + /// The address callouts that briefly lived here are gone. On a screen whose + /// point is the route, a label large enough to read is also large enough to + /// cover the line it is anchored to — and the sheet below already carries the + /// customer, the full address and the live ETA, at a size that does not have + /// to compete with map tiles. The pins mark the ends; the sheet says what + /// they are. + void _setMarkers() { + final kind = stopKindOf(widget.pickup); + _markers + ..clear() + ..addAll([ + milerMarker( + point: _pickupLocation, + width: MilerPin.size + 8, + height: MilerPin.size + MilerPin.tail, + // The stop takes the colour its card carried, not a stock hue. A red + // teardrop and a green one told the rider "map pin" twice; the accent + // tells him which of the three kinds of stop this is. + child: MilerPin( + color: kind.accent, + icon: kind.icon, + emphasised: true, + ), + ), + milerMarker( + point: _dropLocation, + width: MilerPin.size + 8, + height: MilerPin.size + MilerPin.tail, + child: MilerPin( + color: ColorConstants.acceptGreen, + icon: Icons.flag_rounded, + ), + ), + ]); + } + + /// Swaps the stock teardrops for callouts carrying the address. + /// + /// ── Why a bitmap and not a widget ── + /// + /// Google Maps markers are textures, not part of the Flutter tree, so a label + /// beside a pin cannot be a `Text`. The alternative the plugin offers is + /// `InfoWindow`, which only appears on tap and covers the route it is anchored + /// to — which is why it was doing no work here and has been removed. + /// + /// Uber and Rapido both draw the address *into* the marker so it is readable + /// without touching anything: on a map, the question "which building is this" + /// is the whole reason the pin exists. + /// Stops the travelling highlight whenever the screen is not being looked at. + /// + /// The pulse repeats for as long as this route is open, and a rider can sit on + /// this screen for a whole leg. A looping animation is one frame of work every + /// 16ms — harmless while he is watching it, pure drain the moment he switches + /// to Google Maps for turn-by-turn, which is exactly what the Navigate button + /// does. It resumes on return, so he never sees the difference. + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (!mounted) return; + final visible = state == AppLifecycleState.resumed; + + if (!visible && _flowAnim.isAnimating) { + _flowAnim.stop(); + } else if (visible && + !_flowAnim.isAnimating && + _routeAnim.status == AnimationStatus.completed) { + _restartFlow(); + } + } + + Future _createRealRoute() async { + // Rider → stop where we know where he is; the stop's own leg otherwise. + // Recorded so the first GPS fix knows whether it still owes us a redraw. + final origin = _routeOrigin; + _routeStart = origin ?? _pickupLocation; + _routeEnd = origin != null ? _pickupLocation : _dropLocation; + _routedFromRider = origin != null; + + // ── No key to be missing ── + // + // This used to check `ApiConstants.hasMapsKey` and give up before asking, + // because the Directions API needed a credential the app did not have. OSRM + // needs none, so the only question left is whether the network answered. + try { + final points = await MilerRouter.route(_routeStart, _routeEnd); + if (points.isEmpty) { + _useDirectLine(); + return; + } + + _routePoints = points; + _buildDistanceTable(); + if (!mounted) return; + + setState(() { + // Faint full-route "casing" (same red, low opacity) gives depth so + // the bright line reads as drawing *over* the known path. + _casingLine = Polyline( + points: _routePoints, + color: kPrimary.withValues(alpha: 0.18), + strokeWidth: 9, + strokeCap: StrokeCap.round, + strokeJoin: StrokeJoin.round, + ); + _isLoadingRoute = false; + }); + + // ── Camera first, then the line ── + // + // These used to fire together, so the route drew itself while the map + // was still flying to frame it — by the time the camera settled the + // line was already finished, which is why it looked static. Uber lands + // the camera, lets it rest for a beat, and only then traces the route. + unawaited( + _fitMapToRoute().then((_) async { + if (!mounted) return; + await Future.delayed(const Duration(milliseconds: 250)); + if (mounted) _routeAnim.forward(from: 0); + }), + ); + } catch (e) { + debugPrint('[MAP] Route error: $e'); + _useDirectLine(); + } + } + + /// Draws the straight line between the two ends when the road route cannot + /// be had. + /// + /// ── Why draw anything at all ── + /// + /// The route request failing used to clear the spinner and leave the map + /// bare. That is the worst of both: the screen looks finished, so the rider + /// reads the absence of a line as "there is no route", and the one thing the + /// map could still tell him honestly — which direction the stop is in, and + /// roughly how far — was thrown away with the error. + /// + /// Dashed rather than solid, which is the whole of the disclosure: it cannot + /// be mistaken for turn-by-turn guidance at a glance, and it is the only + /// signal available now that the screen has no chrome to carry a message. + /// Navigate still hands off to Google Maps, which routes with its own key and + /// is unaffected by whatever failed here. The cause is in the log. + void _useDirectLine() { + _routePoints = [_routeStart, _routeEnd]; + _buildDistanceTable(); + if (!mounted) { + _isLoadingRoute = false; + return; + } + setState(() { + _casingLine = Polyline( + points: _routePoints, + color: kPrimary.withValues(alpha: 0.35), + strokeWidth: 5, + // Dashed, so it reads as an approximation rather than a road. + pattern: StrokePattern.dashed(segments: const [24, 14]), + strokeCap: StrokeCap.round, + ); + _isLoadingRoute = false; + }); + _fitMapToRoute(); + } + + /// Measures the route so both animations can move at a constant speed. + /// + /// Longitude is scaled by cos(latitude) so a degree east is worth what it is + /// actually worth at this latitude — without it a north-south leg and an + /// east-west leg of the same real length would be measured differently, and + /// the head would change pace when the road turned a corner. + void _buildDistanceTable() { + _cumulative = List.filled(_routePoints.length, 0); + if (_routePoints.length < 2) return; + + final meanLat = + (_routePoints.first.latitude + _routePoints.last.latitude) / 2; + final lonScale = math.cos(meanLat * math.pi / 180).abs(); + + var run = 0.0; + for (var i = 1; i < _routePoints.length; i++) { + final a = _routePoints[i - 1]; + final b = _routePoints[i]; + final dy = b.latitude - a.latitude; + final dx = (b.longitude - a.longitude) * lonScale; + run += math.sqrt(dx * dx + dy * dy); + _cumulative[i] = run; + } + } + + /// The segment index and the fraction across it at [f] of the route's LENGTH. + /// + /// Binary search rather than a scan: this runs several times a frame, and a + /// linear walk over a few thousand vertices is exactly the kind of per-frame + /// cost that shows up as jank on a mid-range phone. + (int, double) _atDistance(double f) { + final total = _cumulative.isEmpty ? 0.0 : _cumulative.last; + if (total <= 0) return (0, 0); + final target = (f.clamp(0.0, 1.0)) * total; + + var lo = 0, hi = _cumulative.length - 1; + while (lo < hi) { + final mid = (lo + hi + 1) >> 1; + if (_cumulative[mid] <= target) { + lo = mid; + } else { + hi = mid - 1; + } + } + final i = lo.clamp(0, _routePoints.length - 2); + final span = _cumulative[i + 1] - _cumulative[i]; + return (i, span <= 0 ? 0.0 : (target - _cumulative[i]) / span); + } + + LatLng _pointOn(int i, double frac) { + final a = _routePoints[i]; + final b = _routePoints[math.min(i + 1, _routePoints.length - 1)]; + return LatLng( + a.latitude + (b.latitude - a.latitude) * frac, + a.longitude + (b.longitude - a.longitude) * frac, + ); + } + + /// Everything from the rider up to [head] of the route's LENGTH, with the + /// leading end interpolated between vertices. + /// + /// The interpolated tip is what keeps the growing end from snapping vertex to + /// vertex: without it the head would jump to the nearest recorded point each + /// frame, which on a dense corner is a visible twitch. + List _prefixByDistance(double head) { + if (_routePoints.length < 2) return const []; + final (idx, frac) = _atDistance(head); + final out = [..._routePoints.sublist(0, idx + 1)]; + if (idx < _routePoints.length - 1) out.add(_pointOn(idx, frac)); + return out; + } + + /// Reveals the bright red route line progressively, once, as an entrance. + /// + /// Eased, unlike the flow that follows it: this is the line arriving, and an + /// entrance is allowed to accelerate. See [_onFlowTick] for why travel is not. + void _onRouteAnimTick() { + if (!mounted || _routePoints.length < 2) return; + final double t = Curves.easeInOutCubic.transform(_routeAnim.value); + + _progressLine = Polyline( + points: _prefixByDistance(t), + color: kPrimary, + // Heavier than the 5 it was, so the drawn line reads as the route and + // the casing beneath it as the ground it covers. + strokeWidth: 6, + strokeCap: StrokeCap.round, + strokeJoin: StrokeJoin.round, + ); + + _mapRepaint.value++; + } + + /// Total route length in metres. + /// + /// [_cumulative] is in latitude-degrees with longitude already scaled by + /// cos(lat), so one unit is one degree of latitude — 111.32 km — everywhere on + /// the route. Good to a fraction of a percent at city scale, and free, which + /// matters because this is only choosing an animation duration. + double get _routeMetres => + _cumulative.isEmpty ? 0 : _cumulative.last * 111320.0; + + /// Starts the flow cycle, timed for this particular route. + /// + /// ── Why the duration is not a constant ── + /// + /// A fixed grow time makes the stream crawl on a 600 m hop and tear across a + /// 9 km one — the same animation reading as two different speeds, which is + /// exactly what "constant speed regardless of route length" rules out. So the + /// grow phase is timed by distance at [_flowMetresPerSecond] and the stream + /// moves at one pace on every stop the rider takes all day. + /// + /// Clamped at both ends only so the extremes stay watchable: under the floor a + /// very short leg would flash, and over the ceiling a cross-city route would + /// hold the rider's eye for longer than a glance at a map is worth. Between + /// them — which is every ordinary first-mile leg — the speed is genuinely + /// constant. + void _restartFlow() { + if (!mounted || _routePoints.length < 2) return; + + final growMs = (_routeMetres / _flowMetresPerSecond * 1000) + .clamp(_flowGrowMinMs, _flowGrowMaxMs) + .round(); + final totalMs = growMs + _flowHoldMs + _flowFadeMs; + + _growShare = growMs / totalMs; + _holdShare = _flowHoldMs / totalMs; + _flowAnim + ..duration = Duration(milliseconds: totalMs) + ..repeat(); + } + + /// One cycle of the flow: grow → hold → fade. + /// + /// 0 ──────────────── grow ────────────────▶ hold ▶─── fade ───▶ 1 + /// white leaves the rider reaches the stop drains back + /// + /// **Grow** is linear on purpose, and sliced by *length* rather than by vertex + /// index. The draw-in is eased because it is an entrance; this is travel, and + /// an eased stream appears to slow down in the middle of the road for no + /// reason. Slicing by length is what keeps it at one pace through a dense + /// junction and a long straight alike — see [_cumulative]. + /// + /// **Fade** is a gradient, not a uniform alpha. A whole line dimming at once + /// is a light being switched off; a front travelling back down the route from + /// the stop to the rider is the stream draining, which is the same motion in + /// reverse and reads as one continuous idea. `flutter_map` will shade a + /// polyline between its two endpoints, so the ramp costs nothing but the + /// stops. + void _onFlowTick() { + if (!mounted || _routePoints.length < 2 || _cumulative.isEmpty) return; + + final frame = routeFlowFrame( + _flowAnim.value, + growShare: _growShare, + holdShare: _holdShare, + ); + final List points; + final List? coreShade; + final List? haloShade; + double flatAlpha = 1; + + if (frame.phase == RouteFlowPhase.grow) { + final head = frame.progress; + points = _prefixByDistance(head); + // A short ramp off the rider's pin so the stream starts rather than + // appears. Without it the first frame is a full-strength stub. + flatAlpha = (head / 0.06).clamp(0.0, 1.0); + coreShade = null; + haloShade = null; + } else if (frame.phase == RouteFlowPhase.hold) { + // Arrived. A beat at full strength, so reaching the stop registers as an + // event instead of being the frame the fade happens to start on. + points = _routePoints; + coreShade = null; + haloShade = null; + } else { + final f = frame.progress; + points = _routePoints; + + coreShade = [ + for (final x in kRouteFlowStops) + Colors.white.withValues( + alpha: _flowCoreAlpha * routeFlowFadeAlpha(f, x), + ), + ]; + haloShade = [ + for (final x in kRouteFlowStops) + _flowHaloColour.withValues( + alpha: _flowHaloAlpha * routeFlowFadeAlpha(f, x), + ), + ]; + + // Nothing left to draw. Clearing rather than returning early matters: a + // bail-out leaves the previous frame's geometry on the map, which is what + // makes a loop look like it freezes before it restarts. + if (coreShade.every((c) => c.a < 0.01)) { + if (_flowHalo != null || _flowCore != null) { + _flowHalo = null; + _flowCore = null; + _mapRepaint.value++; + } + return; + } + } + + if (points.length < 2) { + if (_flowHalo != null || _flowCore != null) { + _flowHalo = null; + _flowCore = null; + _mapRepaint.value++; + } + return; + } + + // Two concentric strokes on the same geometry — a soft halo and a bright + // core — rather than a chain of bands at descending widths. Chained bands + // show their joins as beads, which is the "dotted line" this used to be. + // + // The core is deliberately narrower than the red beneath it (4 against 6) so + // the route stays red at its edges the whole way through. A white line as + // wide as the route does not flow through it; it replaces it. + _flowHalo = Polyline( + points: points, + color: _flowHaloColour.withValues(alpha: _flowHaloAlpha * flatAlpha), + gradientColors: haloShade, + colorsStop: haloShade == null ? null : kRouteFlowStops, + strokeWidth: 10, + strokeCap: StrokeCap.round, + strokeJoin: StrokeJoin.round, + ); + _flowCore = Polyline( + points: points, + color: Colors.white.withValues(alpha: _flowCoreAlpha * flatAlpha), + gradientColors: coreShade, + colorsStop: coreShade == null ? null : kRouteFlowStops, + strokeWidth: 4, + strokeCap: StrokeCap.round, + strokeJoin: StrokeJoin.round, + ); + + // Only the map repaints — see [_mapRepaint]. + _mapRepaint.value++; + } + + /// Frames the leg that is actually drawn. + /// + /// Framed on the line the rider is on — his position to the stop once GPS has + /// a fix, not the booking's pickup → drop pair. The camera used to fit a route + /// the rider was not on. + /// + /// The ten-attempt retry loop this replaces existed because + /// `GoogleMapController.animateCamera` threw until the platform view had + /// finished creating itself. `flutter_map` is a Flutter widget: its camera is + /// available as soon as the map is in the tree, so there is nothing to retry. + Future _fitMapToRoute() async { + if (!mounted) return; + 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), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: kBackground, + body: Stack( + children: [ + // Held back until the push has landed — building a Google Maps + // platform view costs the UI thread enough to eat the slide-in + // whole. See [AfterEntrance]. + AfterEntrance( + placeholder: const MapPlaceholder(), + builder: (context) => + // Rebuilt on its own, sixty times a second, without touching + // the header or the sheet — see [_mapRepaint]. + ValueListenableBuilder( + valueListenable: _mapRepaint, + builder: (context, _, _) => MilerMap( + controller: _mapController, + initialCenter: _pickupLocation, + initialZoom: 14, + polylines: _routeLayers, + markers: _markers, + onReady: () { + if (!_isLoadingRoute && _routeLayers.isNotEmpty) { + _fitMapToRoute(); + } + }, + ), + ), + ), + if (_isLoadingRoute) + Container( + color: Colors.black.withValues(alpha: 0.1), + child: Center(child: CircularProgressIndicator(color: kPrimary)), + ), + _buildHeader(), + _buildDraggableBottomSheet(), + ], + ), + ); + } + + Widget _buildHeader() { + return Positioned( + top: MediaQuery.of(context).padding.top + 12, + left: 16, + right: 16, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: IconButton( + tooltip: 'Back', + onPressed: () => Navigator.pop(context), + icon: Icon( + Icons.arrow_back_ios_new, + color: kTextPrimary, + size: 16, + ), + padding: const EdgeInsets.all(12), + constraints: const BoxConstraints(), + ), + ), + // The floating "Pickup Location" / "Delivery Location" pill that used + // to sit here is gone, along with the 44pt spacer that existed only to + // centre it against the back button. + // + // It was a title for the map rather than information about the stop: + // the sheet below already names the customer, prints the address and + // carries the live ETA, and the rider arrived here from a card that + // told him the stop type. Floating chrome over a map costs the one + // thing a map screen is for — visible map. + ], + ), + ); + } + + Widget _buildDraggableBottomSheet() { + return DraggableScrollableSheet( + initialChildSize: 0.45, + minChildSize: 0.35, + maxChildSize: 0.75, + // ── Frosted, not opaque ── + // + // A solid white slab over a map takes away 45% of the one thing this + // screen exists to show, permanently. Frosted, the rider keeps the block + // his stop is on in view while he reads the address printed over it. See + // [milerGlassSheet]. + 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), + ), + ), + Expanded( + // Content settles in (fade + gentle rise) as the route finishes + // loading, so the sheet feels alive alongside the drawing line. + child: AnimatedSlide( + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutCubic, + offset: _isLoadingRoute ? const Offset(0, 0.06) : Offset.zero, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 500), + opacity: _isLoadingRoute ? 0.0 : 1.0, + // ── One sheet, not a stack of cards ── + // + // This was two containers: a filled customer card, then an + // outlined white one holding the ETA and the address. An + // outline drawn around white, on a white sheet, is a line the + // rider has to read past to reach the only thing he came here + // for — and it is the opposite of what the rest of the app + // does, which is to fill a panel and never draw its edge (see + // [ColorConstants.cardSurface]). + // + // A bottom sheet is already a container. Everything inside it + // is one column: the ETA leads because it is the live fact, + // one hairline separates it from the stop it belongs to, and + // the actions close it. This is the shape Uber's arrival sheet + // has, and it is why that sheet reads at arm's length. + child: ListView( + controller: scrollController, + padding: EdgeInsets.fromLTRB(20.w, 18.h, 20.w, 8.h), + children: [ + _LiveEtaHeader( + meters: _liveMetersToStop, + speedMps: _livePosition?.speed, + live: _livePosition != null, + ), + SizedBox(height: 16.h), + Divider( + height: 1, + thickness: 1, + color: ColorConstants.borderSubtle, + ), + SizedBox(height: 16.h), + _buildStopBrief(), + SizedBox(height: 24.h), + _buildSwipeButton(), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + /// Who the stop is, and where — as rows on the sheet, not as cards on it. + /// + /// ── The name used to be printed twice ── + /// + /// A filled customer card carried `pickupcustomer` as its title, and the route + /// node inside the outlined box below carried the same string again as *its* + /// title. Two boxes, two borders and one fact stated twice, on the screen with + /// the least room for any of it: the sheet opens at 45% of the height and the + /// map owns the rest. + /// + /// The block itself is [_StopBrief], shared with Update Status and Skip. Only + /// the status pill is local to this screen. + Widget _buildStopBrief() { + final status = (widget.pickup['orderstatus'] ?? '') + .toString() + .toUpperCase(); + + 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, + ), + ), + ), + ), + ); + } + + Widget _buildSwipeButton() { + if (_isNavigating) { + return Container( + height: ButtonSizes.primary, + decoration: BoxDecoration( + color: ColorConstants.acceptGreen, + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + child: const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ), + ), + ); + } + + // Two clear actions: re-launch navigation (in case the rider dismissed + // Google Maps) and confirm arrival. Slides are reserved for money. + // + // ── Both green, only one filled ── + // + // These were both `kPrimary` — the brand maroon — which put the forward + // action of the whole flow in the same hue as Reject, Cancel and every + // destructive confirmation in the app. One rule holds now: green advances + // the job, maroon is brand and navigation, red destroys. + // + // Navigate stays *outlined* because it is the secondary of the pair — a way + // 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. + return SizedBox( + height: ButtonSizes.primary, + width: double.infinity, + child: Row( + children: [ + Expanded( + flex: 4, + child: MilerButton( + label: 'Navigate', + icon: Icons.navigation_rounded, + variant: MilerButtonVariant.outlined, + color: ColorConstants.acceptGreen, + onPressed: () { + _hasOpenedNavigation = false; + _openGoogleMapsNavigation(); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 6, + child: MilerButton( + label: "I've arrived", + icon: Icons.check_circle_rounded, + color: ColorConstants.acceptGreen, + onPressed: () async { + debugPrint('[ARRIVED] Tapped "I have arrived" on map screen'); + await _handleArrived(); + }, + ), + ), + ], + ), + ); + } + + Future _handleArrived() async { + if (_handlingArrival || _isNavigating || !mounted) return false; + _handlingArrival = true; + + try { + final verifyResult = await openSheet>( + context, + StopVerificationPage(pickup: widget.pickup), + ); + + if (verifyResult == null || + verifyResult['verified'] != true || + !mounted) { + _handlingArrival = false; + return false; + } + + await _startPickupNavigation(verifyResult); + } catch (e) { + if (mounted) { + AppFeedback.error(context, 'Could not open that stop — try again'); + } + _handlingArrival = false; + return false; + } + _handlingArrival = false; + return true; + } + + Future _startPickupNavigation( + Map verificationData, + ) async { + if (_isNavigating || !mounted) return; + setState(() => _isNavigating = true); + + try { + final dc = Get.put(PickupsController(), permanent: true); + final d = widget.pickup; + final int pickupId = + int.tryParse( + '${d['PickupId'] ?? d['pickupId'] ?? d['pickupid'] ?? 0}', + ) ?? + 0; + final String orderId = (d['orderid'] ?? d['OrderId'] ?? '').toString(); + final int orderHeaderId = + int.tryParse('${d['orderheaderid'] ?? d['OrderHeaderId'] ?? 0}') ?? 0; + + if (pickupId > 0) { + final prefs = await SharedPreferences.getInstance(); + // He is at the door: verification has just passed. Read back at + // completion — see [addCompletedBookings]. + await prefs.setString( + kStopArrivedKey(pickupId), + DateTime.now().toIso8601String(), + ); + final rawEta = d['eta']; + int etaMinutes = int.tryParse(rawEta?.toString() ?? '0') ?? 0; + if (etaMinutes > 0) { + final now = DateTime.now(); + final endTime = + now.add(Duration(minutes: etaMinutes)).millisecondsSinceEpoch ~/ + 1000; + await prefs.setInt('eta_endtime_$orderId', endTime); + } + } + + String riderLatStr = '0', 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 (_) {} + + if (!mounted) { + _endNavigating(); + return; + } + + // Mark this pickup active — the rider has arrived and verified the + // parcel. (Done before the confirm sheet so live tracking / active + // state is in place while confirming.) + final _MyPickupsState? parentState = + widget.parentState ?? + context.findAncestorStateOfType<_MyPickupsState>(); + if (pickupId > 0 && orderId.isNotEmpty) { + dc.updateActiveStatus( + pickupId: pickupId, + orderHeaderId: orderHeaderId, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + orderId: orderId, + ); + + if (parentState != null) { + parentState._activePickupOrderId = orderId; + d['orderstatus'] = 'active'; + await parentState._startPickupPosting(d); + parentState.setState(() {}); + } + } + + if (!mounted) { + _endNavigating(); + return; + } + + // ── Step 2 of the v1 pickup flow: what he actually took ── + // + // This is the last moment the parcel's real weight can be recorded. + // `pickup-complete` (step 4, a few lines below) recomputes the chargeable + // weight from whatever `parcel` submitted, and once the booking has + // become a consignment there is nothing left to attach a measurement to. + // The rider typed it on the verification screen thirty seconds ago and + // the app used to throw it away. + // + // 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( + pickupId, + verificationData, + ); + if (parcelRes?['status'] != true) { + debugPrint( + '[PARCEL] booking $pickupId: not recorded — $parcelRes', + ); + } + } + + if (!mounted) { + _endNavigating(); + 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 (!mounted) { + _endNavigating(); + return; + } + + // The sheet returns an outcome map on picked-up / cancelled. We (a normal + // page route) show the success screen with its "Move to next stop" + // button. + debugPrint('[CONFIRM] sheet returned: $result'); + + if (result is Map && + (result['outcome'] == 'completed' || + result['outcome'] == 'cancelled')) { + // ── The bookkeeping must never cost the rider his next stop ── + // + // Removing the finished stop from the live list and reading the + // remainder used to sit inside the same try as the navigation below, + // so anything that threw in here — a disposed parent, a prefs write — + // was caught by the outer handler, which logged and set a flag. The + // stop was completed on the server and the rider was left standing on + // the map screen with nothing to press, which reads exactly like a + // dead Confirm button. + // + // The stop is finished either way, so the hand-off screen opens either + // way; a failure here costs at most a stale "next stops" list, which + // that screen re-fetches for itself. + final bool cancelled = result['outcome'] == 'cancelled'; + + // ── Stamp the compliance while it is still knowable ── + // + // The ETA deadline lives in a prefs key the next stop overwrites, and + // the distance is measured from the previous stop's position, which has + // also moved on. `PickupsController` has just computed both to decide + // the bonus; this is the one moment they can be written down. See + // [StopCompliance]. + try { + widget.pickup['compliance'] = StopCompliance( + onTime: dc.lastOnTime.value, + lateBy: dc.lastLateBy.value, + actualKm: dc.lastRiderKms.value > 0 ? dc.lastRiderKms.value : null, + plannedKm: double.tryParse((widget.pickup['kms'] ?? '').toString()), + ).toJson(); + + // ── What was actually handled, and what was actually taken ── + // + // The weight the rider measured, the parcels he counted and the money + // he collected exist only in the two sheets he has just closed. They + // are the answers to every question asked of a finished stop + // afterwards — "was that the 4.5 kg one?", "did he take the cash?" — + // and without this they die with the sheets. + widget.pickup['proof'] = { + if ((verificationData['weight'] ?? '').toString().trim().isNotEmpty) + 'weight': verificationData['weight'], + if (verificationData['pickup'] is Map) + 'collected': (verificationData['pickup'] as Map)['collected'], + if (verificationData['delivery'] is Map) + 'handedover': (verificationData['delivery'] as Map)['handedOver'], + if (result['paymentMethod'] != null) + 'paymentmethod': result['paymentMethod'], + if (result['amountCollected'] != null) + 'amountcollected': result['amountCollected'], + }; + } catch (e) { + debugPrint('[CONFIRM] could not stamp completion detail: $e'); + } + + List>? remaining; + try { + if (parentState != null) { + parentState.markPickupFinished(widget.pickup, cancelled: cancelled); + remaining = parentState.remainingPickups(); + } else { + // Reached without the Bookings state behind it — from Home's live + // banner, or after a rebuild. The stop is still finished, and + // Activity still has to know. + await addCompletedBookings([widget.pickup], cancelled: cancelled); + } + } catch (e) { + debugPrint('[CONFIRM] post-completion bookkeeping failed: $e'); + } + + if (!mounted) { + _endNavigating(); + return; + } + + replaceWithSheet( + context, + PickupsDone( + isCancelled: cancelled, + isDelivery: result['isDelivery'] == true, + bonusPoints: (result['bonusPoints'] as int?) ?? 0, + paymentMethod: result['paymentMethod'] as String?, + remainingOrders: remaining, + parentState: parentState, + ), + ); + } else if (result == true) { + // Skipped. The stop is parked, so leave the map and go back to the list + // one route at a time rather than unwinding to the app root. + Navigator.of(context).pop(); + } + // Dismissed (null): stay on the map. Swiping a sheet away is a change of + // mind about the sheet, not about the stop — this used to + // `popUntil(isFirst)`, which threw the rider all the way out to the tab + // shell and lost the stop he was working. + _endNavigating(); + } catch (e) { + debugPrint('[ERROR] _startPickupNavigation: $e'); + _endNavigating(); + // Never fail silently here. Everything in this method sits between the + // rider pressing a button and the screen changing, so a swallowed + // exception looks like a dead button and he will press it again. + if (mounted) { + AppFeedback.error(context, "Couldn't finish that stop — try again"); + } + } + } +} + +// ── Live ETA header (Uber-style) ───────────────────────────────────────────── +// +// Previously this was a yellow warning-coloured box. Two things were wrong +// with that: amber is the app's WARNING colour, so a perfectly healthy stop +// looked like a problem; and boxing the number inside a tinted container made +// it compete with the address block underneath instead of leading it. +// +// Uber's pattern, and the one used here: the arrival time is simply the +// biggest thing on the card. No container, no fill, no border — hierarchy is +// carried by type size alone, and a hairline divider separates it from the +// address. The number is the headline; everything else is a caption. +// +// 8 min ● LIVE +// 2.4 km away · Arrive by 4:35 PM +// ────────────────────────────────────────────── +// PICKUP LOCATION … +class _LiveEtaHeader extends StatelessWidget { + final double? meters; + final double? speedMps; + final bool live; + + const _LiveEtaHeader({ + required this.meters, + required this.speedMps, + required this.live, + }); + + @override + Widget build(BuildContext context) { + final travel = RouteMetricsHelper.travelTime( + meters, + liveSpeedMps: speedMps, + ); + final distance = RouteMetricsHelper.formatDistance(meters); + final arrival = RouteMetricsHelper.formatEta( + meters, + liveSpeedMps: speedMps, + ); + final known = travel > Duration.zero; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // The headline: minutes away, at display size. + // + // Expanded, not a bare Text with a Spacer after it. At display size + // "Calculating…" is already 20pt, and the row overflowed from about + // 1.3x system text upward — striping the sheet's own headline, on + // the screen a rider reads at arm's length precisely because he has + // the font turned up. + Expanded( + child: Text( + known + ? RouteMetricsHelper.formatDuration(travel) + : 'Calculating…', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: known ? 30 : 20, + height: 1.05, + fontWeight: FontWeight.w800, + letterSpacing: -1.0, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + const SizedBox(width: 10), + // A live badge only when a position stream is actually feeding + // this. A stale ETA that looks live is worse than no ETA. + if (live) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: ColorConstants.acceptGreen, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 5), + Text( + 'LIVE', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: ColorConstants.acceptGreen, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 3), + Text( + known + ? '$distance away · Arrive by $arrival' + : 'Waiting for a GPS fix', + style: TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w700, + letterSpacing: -0.1, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ); + } +} + +/// ── The flow cycle, as arithmetic ── +/// +/// Top-level and pure so the behaviour can be asserted directly. The animation +/// itself sits behind a live route, a `TickerProvider` and a map, none of which +/// a widget test can stand up — but "the white line starts at the rider, reaches +/// the stop, then drains back to him" is a claim about numbers, and this is +/// where it is held. See `test/route_flow_test.dart`. + +/// Which part of the cycle a frame is in. +enum RouteFlowPhase { + /// White growing from the rider towards the stop. + grow, + + /// Arrived, holding at full strength. + hold, + + /// Draining back from the stop towards the rider. + fade, +} + +/// Where in the cycle [t] falls, and how far through that phase it is. +/// +/// [progress] is always 0→1 *within* the phase, so callers never re-derive the +/// phase boundaries — which is how the grow and the fade drifted apart when this +/// was inline. +({RouteFlowPhase phase, double progress}) routeFlowFrame( + double t, { + required double growShare, + required double holdShare, +}) { + final clamped = t.clamp(0.0, 1.0); + if (growShare > 0 && clamped <= growShare) { + return (phase: RouteFlowPhase.grow, progress: clamped / growShare); + } + if (clamped <= growShare + holdShare) { + final span = holdShare; + return ( + phase: RouteFlowPhase.hold, + progress: span <= 0 ? 1.0 : (clamped - growShare) / span, + ); + } + final span = 1 - growShare - holdShare; + return ( + phase: RouteFlowPhase.fade, + progress: span <= 0 ? 1.0 : (clamped - growShare - holdShare) / span, + ); +} + +/// Gradient stops the fade is sampled at, along the route. +const List kRouteFlowStops = [0, 0.25, 0.5, 0.75, 1]; + +/// Softness of the draining front, as a share of the route's length. A hard +/// edge travelling down the line reads as a wipe; this is what makes it a fade. +const double kRouteFlowFadeRamp = 0.35; + +/// Opacity multiplier at position [x] along the route (0 = rider, 1 = stop) +/// when the fade is [f] through. +/// +/// The front starts just past the stop and travels to just past the rider, so +/// the line is whole at f=0 and gone at f=1 rather than already clipped at +/// either end — which is what made the old loop appear to restart early. +double routeFlowFadeAlpha( + double f, + double x, { + double ramp = kRouteFlowFadeRamp, +}) { + final front = (1 + ramp) - f.clamp(0.0, 1.0) * (1 + 2 * ramp); + return ((front - x) / ramp).clamp(0.0, 1.0); +} diff --git a/lib/views/Dashboard/pickups/map_btn.dart b/lib/views/Dashboard/pickups/map_btn.dart new file mode 100644 index 0000000..fb4888a --- /dev/null +++ b/lib/views/Dashboard/pickups/map_btn.dart @@ -0,0 +1,39 @@ +part of 'pickups.dart'; + +// ------------------------------------------------------------------------- +// MAP VIEW BUTTON (Customer locations) +// ------------------------------------------------------------------------- +class MapViewRow extends StatelessWidget { + final List> pickup; + final Map? preservedStepNumbers; + + const MapViewRow({ + super.key, + required this.pickup, + this.preservedStepNumbers, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () { + openScreen( + context, + MultiCustomerMapScreen( + pickup: pickup, + preservedStepNumbers: preservedStepNumbers ?? {}, + ), + swipeToGoBack: false, + ); + }, + child: Image.asset( + 'assets/images/customermap.png', + color: ColorConstants.primary, + height: 32, + width: 32, + errorBuilder: (c, e, s) => + Icon(Icons.map, size: 32, color: ColorConstants.primary), + ), + ); + } +} diff --git a/lib/views/Dashboard/pickups/multi_map.dart b/lib/views/Dashboard/pickups/multi_map.dart new file mode 100644 index 0000000..4c3b453 --- /dev/null +++ b/lib/views/Dashboard/pickups/multi_map.dart @@ -0,0 +1,878 @@ +part of 'pickups.dart'; + +class _StepPoint { + final int step; + final LatLng position; + _StepPoint(this.step, this.position); +} + +// ------------------------------------------------------------------------- +// MULTI CUSTOMER MAP (Shows all pickup with real step numbers) +// ------------------------------------------------------------------------- +class MultiCustomerMapScreen extends StatefulWidget { + final List> pickup; + final Map preservedStepNumbers; + + const MultiCustomerMapScreen({ + super.key, + required this.pickup, + this.preservedStepNumbers = const {}, + }); + + @override + State createState() => _MultiCustomerMapScreenState(); +} + +class _MultiCustomerMapScreenState extends State + with SingleTickerProviderStateMixin { + final MapController mapController = MapController(); + late final MilerMapCamera _camera; + List markers = []; + + /// The route as known, faint, underneath. + List casingLines = []; + + /// The bright line drawing itself in over the casing. Named rather than kept + /// in a set keyed by 'route_progress': `flutter_map` draws a list in order, so + /// the order is the z-order and the id had nothing left to do. + Polyline? progressLine; + + List get routeLayers => [ + ...casingLines, + if (progressLine != null) progressLine!, + ]; + + /// Bumped whenever a route layer changes mid-animation, so only the map + /// is rebuilt rather than the whole screen. + final ValueNotifier _mapRepaint = ValueNotifier(0); + LatLng? currentLocation; + bool _isLoading = true; + bool _mapReady = false; + final Map> _pickupMap = {}; + + /// Every routed point, hub to last stop, in riding order. + /// + /// The segments are fetched one leg at a time, but the *animation* has to run + /// across the whole route — drawing each leg in turn would restart at every + /// stop and read as five separate lines rather than one journey. + final List _fullRoute = []; + + late final AnimationController _routeAnim; + + @override + void initState() { + super.initState(); + // Long enough to read as a route being traced rather than a line appearing; + // short enough that the rider is not waiting on it to see his stops. + _camera = MilerMapCamera(controller: mapController, vsync: this); + _routeAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + )..addListener(_onRouteTick); + if (widget.pickup.isNotEmpty) { + final firstPickup = widget.pickup.first; + final dropLat = _parseD( + firstPickup['droplat'] ?? firstPickup['pickuplat'] ?? 0, + ); + final dropLon = _parseD( + firstPickup['droplon'] ?? firstPickup['pickuplong'] ?? 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 Booking) { + final dynamic raw = Booking['step'] ?? Booking['Step']; + final int step = raw == null + ? 0 + : (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0); + return step; + } + + String _getOrderKey(Map Booking) { + final pickupId = (Booking['pickupid'] ?? '').toString(); + final orderId = (Booking['orderid'] ?? '').toString(); + return pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId'; + } + + int _getPreservedOrCurrentStep(Map Booking) { + final orderKey = _getOrderKey(Booking); + if (widget.preservedStepNumbers.containsKey(orderKey)) { + return widget.preservedStepNumbers[orderKey]!; + } + final currentStep = _getStepNumber(Booking); + return currentStep; + } + + Future _loadMapData() async { + try { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + + _getCurrentLocation().then((_) { + if (mounted) { + _createPickupMarkers(); + } + }); + } 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? _getNextPickupStep() { + // Find the first non-skipped pickup (next pickup from user location) + for (int i = 0; i < widget.pickup.length; i++) { + final pickup = widget.pickup[i]; + final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + if (status != 'skipped') { + final stepNumber = _getPreservedOrCurrentStep(pickup); + if (stepNumber > 0) { + return stepNumber; + } else { + // Calculate display step for bookings without step + final ordersWithStepBefore = widget.pickup + .sublist(0, i) + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + final totalOrdersWithStep = widget.pickup + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + return totalOrdersWithStep + (i - ordersWithStepBefore) + 1; + } + } + } + return null; + } + + Future _createPickupMarkers() async { + final tempMarkers = []; + List<_StepPoint> stepPoints = []; + _pickupMap.clear(); + + final nextStep = _getNextPickupStep(); + + for (int i = 0; i < widget.pickup.length; i++) { + final pickup = widget.pickup[i]; + final stepNumber = _getPreservedOrCurrentStep(pickup); + + int displayStep; + if (stepNumber > 0) { + displayStep = stepNumber; + } else { + final ordersWithStepBefore = widget.pickup + .sublist(0, i) + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + final totalOrdersWithStep = widget.pickup + .where((o) => _getPreservedOrCurrentStep(o) > 0) + .length; + displayStep = totalOrdersWithStep + (i - ordersWithStepBefore) + 1; + } + + final double lat = _parseD(pickup['droplat'] ?? pickup['pickuplat']); + final double lon = _parseD(pickup['droplon'] ?? pickup['pickuplong']); + if (lat == 0 || lon == 0) continue; + + final orderId = (pickup['orderid'] ?? '').toString(); + final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final isSkipped = status == 'skipped'; + final isNext = displayStep == nextStep; + + // Save pickup data for dialog + _pickupMap['pickup_$orderId'] = pickup; + + // ── A widget, not a bitmap ── + // + // This used to await `_createCircularMarkerBitmap`, a hundred lines that + // opened a `PictureRecorder`, painted a circle and a number onto a canvas, + // rasterised it to PNG bytes and handed the bytes to the platform — per + // marker, per rebuild, asynchronously, because a Google Maps marker is a + // texture and cannot contain a `Text`. A `flutter_map` marker is a widget, + // so the number is just a number, in the app's own font. + // + // The tap that opened the customer sheet moves onto the widget too. The + // `InfoWindow` that carried the name is gone with it: it only appeared on + // tap, covered the route it was anchored to, and said what the sheet the + // same tap opens says properly. + tempMarkers.add( + milerMarker( + point: LatLng(lat, lon), + width: MilerNumberPin.size + 8, + height: MilerNumberPin.size + MilerNumberPin.tail, + child: GestureDetector( + onTap: () => _showCustomerDetailsSheet(pickup, displayStep), + child: MilerNumberPin( + number: displayStep, + color: isSkipped + ? ColorConstants.warning + : isNext + ? ColorConstants.primary + : ColorConstants.secondaryText, + ), + ), + ), + ); + + stepPoints.add(_StepPoint(displayStep, LatLng(lat, lon))); + } + + if (currentLocation != null) { + tempMarkers.add( + milerMarker( + point: currentLocation!, + width: MilerRiderDot.size, + height: MilerRiderDot.size, + child: const MilerRiderDot(), + ), + ); + stepPoints.insert(0, _StepPoint(0, currentLocation!)); + } + + stepPoints.sort((a, b) => a.step.compareTo(b.step)); + + final newPolylines = []; + _fullRoute.clear(); + + for (int i = 0; i < stepPoints.length - 1; i++) { + final start = stepPoints[i].position; + final end = stepPoints[i + 1].position; + + // OSRM instead of Directions — same answer, no key. See [MilerRouter]. + final routePoints = await MilerRouter.route(start, end); + + if (routePoints.isNotEmpty) { + _fullRoute.addAll(routePoints); + newPolylines.add( + Polyline( + points: routePoints, + strokeWidth: 6, + // The brand accent, matching the single-stop map. This was + // `Colors.blue`, so the two map screens drew the same route in two + // different colours. + color: ColorConstants.primary, + strokeCap: StrokeCap.round, + strokeJoin: StrokeJoin.round, + ), + ); + } else { + // A leg the router could not resolve still has to enter the animated + // path, or the trace stops dead at the first unroutable stop. + _fullRoute.addAll([start, end]); + newPolylines.add( + Polyline( + points: [start, end], + strokeWidth: 4, + // Straight-line fallback — lighter, because it is a guess at the + // route rather than the route. + color: ColorConstants.primary.withValues(alpha: 0.35), + pattern: StrokePattern.dashed(segments: const [20, 12]), + ), + ); + } + } + + if (mounted) { + setState(() { + markers = tempMarkers; + // The resolved segments become the faint casing — the route as known — + // and the bright line is then traced over them. Same two-layer treatment + // the single-stop map uses, so the two screens animate identically. + casingLines = [ + for (final p in newPolylines) + Polyline( + points: p.points, + color: ColorConstants.primary.withValues(alpha: 0.18), + strokeWidth: 9, + pattern: p.pattern, + strokeCap: StrokeCap.round, + strokeJoin: StrokeJoin.round, + ), + ]; + }); + if (_fullRoute.length > 1) { + _buildDistanceTable(); + _routeAnim.forward(from: 0); + } + } + + await Future.delayed(const Duration(milliseconds: 200)); + _fitBoundsToMarkersAndPolylines(); + } + + /// Distance from the route's start to each point in [_fullRoute]. + /// + /// The animation walks this table rather than the point list, and that is the + /// difference between the head moving and the head stuttering. A routing API + /// puts vertices where the road bends, so a roundabout can carry twenty of + /// them across thirty metres while a bypass carries two across a kilometre. + /// Stepping by index spends equal time on each, which makes the head crawl + /// through every junction and leap down every straight. + List _cumulative = []; + + void _buildDistanceTable() { + _cumulative = List.filled(_fullRoute.length, 0); + if (_fullRoute.length < 2) return; + + // Longitude degrees shrink with latitude, so scale them before measuring or + // an east–west leg is over-weighted. Plane geometry is ample here: the table + // only has to get the *ratios* between legs right. + final meanLat = + _fullRoute.map((p) => p.latitude).reduce((a, b) => a + b) / + _fullRoute.length; + final lonScale = math.cos(meanLat * math.pi / 180).abs().clamp(0.05, 1.0); + + for (var i = 1; i < _fullRoute.length; i++) { + final a = _fullRoute[i - 1], b = _fullRoute[i]; + final dy = b.latitude - a.latitude; + final dx = (b.longitude - a.longitude) * lonScale; + _cumulative[i] = _cumulative[i - 1] + math.sqrt(dy * dy + dx * dx); + } + } + + /// The point [f] of the way along the route *by length*, as a segment index + /// plus a fraction into that segment. + (int, double) _atDistance(double f) { + final total = _cumulative.isEmpty ? 0.0 : _cumulative.last; + if (total <= 0) return (0, 0); + + final target = (f.clamp(0.0, 1.0)) * total; + var lo = 0, hi = _cumulative.length - 1; + while (lo < hi) { + final mid = (lo + hi) >> 1; + if (_cumulative[mid] <= target) { + lo = mid + 1; + } else { + hi = mid; + } + } + final i = (lo - 1).clamp(0, _fullRoute.length - 2); + final span = _cumulative[i + 1] - _cumulative[i]; + return ( + i, + span <= 0 ? 0.0 : ((target - _cumulative[i]) / span).clamp(0.0, 1.0), + ); + } + + /// Reveals the bright line progressively, with an interpolated leading tip. + void _onRouteTick() { + if (!mounted || _fullRoute.length < 2) return; + if (_cumulative.length != _fullRoute.length) _buildDistanceTable(); + + final t = Curves.easeInOutCubic.transform(_routeAnim.value); + final (idx, frac) = _atDistance(t); + + final shown = [..._fullRoute.sublist(0, idx + 1)]; + if (idx < _fullRoute.length - 1) { + final a = _fullRoute[idx]; + final b = _fullRoute[idx + 1]; + shown.add( + LatLng( + a.latitude + (b.latitude - a.latitude) * frac, + a.longitude + (b.longitude - a.longitude) * frac, + ), + ); + } + + progressLine = Polyline( + points: shown, + color: ColorConstants.primary, + strokeWidth: 6, + strokeCap: StrokeCap.round, + strokeJoin: StrokeJoin.round, + ); + + // Only the map rebuilds — a `setState` here redrew the whole screen, sheet + // and all, sixty times a second to move one line. + _mapRepaint.value++; + } + + /// Frames every stop and every metre of route. + /// + /// The retry-with-delay dance this replaces existed because + /// `GoogleMapController.animateCamera` threw until the platform view had + /// finished creating itself. `flutter_map`'s camera is available as soon as + /// the map is in the tree. + Future _fitBoundsToMarkersAndPolylines() async { + if (!mounted) return; + + final points = [ + for (final m in markers) m.point, + for (final poly in routeLayers) ...poly.points, + ]; + if (points.isEmpty) return; + + await _camera.fit(points, padding: const EdgeInsets.all(64)); + } + + 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)), + ), + builder: (BuildContext context) { + final customerName = (pickup['pickupcustomer'] ?? 'Customer') + .toString(); + final address = (pickup['pickupaddress'] ?? 'Address not available') + .toString(); + final phone = (pickup['pickupcontactno'] ?? '').toString(); + final orderId = (pickup['orderid'] ?? '').toString(); + final status = (pickup['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.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, + ), + ), + ], + ), + ), + ], + ), + 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: 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), + ], + ), + ), + ), + ), + ); + }, + ); + } + + @override + void dispose() { + // The class had no dispose before the route animation was added — a ticker + // outliving its State keeps the whole subtree alive and fires into a widget + // that is no longer mounted. + _routeAnim.dispose(); + _mapRepaint.dispose(); + _camera.dispose(); + mapController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + LatLng initialTarget; + double initialZoom = 13; + + if (currentLocation != null) { + initialTarget = currentLocation!; + } else if (widget.pickup.isNotEmpty) { + final firstPickup = widget.pickup.first; + final lat = _parseD(firstPickup['droplat'] ?? firstPickup['pickuplat']); + final lon = _parseD(firstPickup['droplon'] ?? firstPickup['pickuplong']); + initialTarget = (lat != 0 && lon != 0) + ? LatLng(lat, lon) + : const LatLng(11.0168, 76.9558); + } else { + initialTarget = const LatLng(11.0168, 76.9558); + } + + return Scaffold( + // ── A light app bar, not a red slab ── + // + // This was a solid brand-red bar with white text, 80pt tall, built out of + // a PreferredSize wrapping an AppBar whose `leading` was an infinitely + // wide Row holding both the back arrow and the title. Two problems: a + // saturated red band across the top of a map competes with the route drawn + // on it — the map is the content, and the chrome was louder than it — and + // every other pushed screen in the app has a white bar, so this one read + // as a different app. + // + // 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, + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : AfterEntrance( + placeholder: const MapPlaceholder(), + builder: (context) => ValueListenableBuilder( + valueListenable: _mapRepaint, + builder: (context, _, _) => MilerMap( + controller: mapController, + initialCenter: initialTarget, + initialZoom: initialZoom, + polylines: routeLayers, + markers: markers, + onReady: () { + _mapReady = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_mapReady) _fitBoundsToMarkersAndPolylines(); + }); + }, + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/pickups/payment_screen.dart b/lib/views/Dashboard/pickups/payment_screen.dart new file mode 100644 index 0000000..2422747 --- /dev/null +++ b/lib/views/Dashboard/pickups/payment_screen.dart @@ -0,0 +1,853 @@ +import 'package:flutter/material.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/views/helpers/widgets/app_widgets.dart'; +import 'package:flutter_screenutil/flutter_screenutil.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'; + +class PaymentScreen extends StatefulWidget { + final Map pickup; + final Map verificationData; + + const PaymentScreen({ + super.key, + required this.pickup, + required this.verificationData, + }); + + @override + State createState() => _PaymentScreenState(); +} + +class _PaymentScreenState extends State + with TickerProviderStateMixin { + String _paymentMethod = 'cash'; + bool _isProcessing = false; + bool _isSuccess = false; + bool _paymentVerified = false; + double _pickupCharge = 0.0; + double _totalAmount = 0.0; + + late final String _customerName; + late final String _orderId; + late final String _bookingId; + late final String _trackingId; + late final String _weight; + late final String _description; + + late AnimationController _successAnimController; + late Animation _successScaleAnim; + late Animation _successFadeAnim; + + @override + void initState() { + super.initState(); + final d = widget.pickup; + final v = widget.verificationData; + _customerName = (d['pickupcustomer'] ?? 'Customer').toString(); + _orderId = (d['orderid'] ?? '').toString(); + _bookingId = _orderId.isNotEmpty + ? _orderId + : 'DM${DateTime.now().millisecondsSinceEpoch.toString().substring(5)}'; + _trackingId = + (d['trackingid'] ?? + 'TRK${DateTime.now().millisecondsSinceEpoch.toString().substring(4)}') + .toString(); + _weight = (v['weight'] ?? '0').toString(); + _description = (v['description'] ?? '').toString(); + _pickupCharge = double.tryParse(_weight) != null + ? double.parse(_weight) * 15.0 + : 30.0; + _totalAmount = _pickupCharge; + + _successAnimController = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + ); + _successScaleAnim = CurvedAnimation( + parent: _successAnimController, + curve: Curves.elasticOut, + ); + _successFadeAnim = CurvedAnimation( + parent: _successAnimController, + curve: Curves.easeOut, + ); + } + + @override + void dispose() { + _successAnimController.dispose(); + super.dispose(); + } + + Future _processPayment() async { + if (_isProcessing) return; + setState(() => _isProcessing = true); + + // Simulate backend payment verification + await Future.delayed(const Duration(milliseconds: 1500)); + + if (!mounted) return; + + // Verify payment was successful + _paymentVerified = true; + setState(() { + _isProcessing = false; + _isSuccess = true; + }); + + // Start success animation + _successAnimController.forward(); + + // Auto-navigate after showing success (optional) + await Future.delayed(const Duration(milliseconds: 2500)); + if (mounted && _isSuccess) { + _completePayment(); + } + } + + void _completePayment({bool goToBookings = false}) { + if (!mounted) return; + Navigator.pop(context, { + 'paid': true, + 'goto_bookings': goToBookings, + 'verified': true, + }); + } + + @override + Widget build(BuildContext context) { + if (_isSuccess) return _buildSuccessView(); + return _buildPaymentView(); + } + + // ────────────────────────────── SUCCESS VIEW ───────────────────────────── + Widget _buildSuccessView() { + return Scaffold( + backgroundColor: ColorConstants.surface, + body: SafeArea( + child: FadeTransition( + opacity: _successFadeAnim, + child: Padding( + padding: EdgeInsets.all(24.r), + child: Column( + children: [ + const Spacer(flex: 2), + + // Success animation circle + ScaleTransition( + scale: _successScaleAnim, + child: Container( + width: 110.r, + height: 110.r, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + ColorConstants.success, + ColorConstants.acceptGreen, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: ColorConstants.success.withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Icon( + Icons.check_rounded, + color: Colors.white, + size: 52.sp, + ), + ), + ), + SizedBox(height: 24.h), + + Text( + 'Payment Collected Successfully', + style: TextStyle( + fontSize: 26.sp, + fontWeight: FontWeight.w900, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 8.h), + Text( + '₹${_totalAmount.toStringAsFixed(0)} via ${_paymentMethod.toUpperCase()}', + style: TextStyle( + fontSize: 15.sp, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 32.h), + + // Receipt Card - Corporate Design + Container( + width: double.infinity, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular( + DesignConstants.radiusXl, + ), + border: Border.all(color: ColorConstants.outlineVariant), + boxShadow: DesignConstants.shadowSm, + ), + child: Column( + children: [ + // Receipt header + Container( + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + ColorConstants.primary.withValues(alpha: 0.08), + ColorConstants.primary.withValues(alpha: 0.03), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.vertical( + top: Radius.circular(18.r), + ), + ), + child: Row( + children: [ + Container( + padding: EdgeInsets.all(10.r), + decoration: BoxDecoration( + color: ColorConstants.primaryContainer + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + ), + child: Icon( + Icons.receipt_long_rounded, + color: ColorConstants.primary, + size: 18.sp, + ), + ), + SizedBox(width: 10.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Payment Receipt', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + Text( + 'Payment verified and recorded', + style: TextStyle( + fontSize: 12.sp, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + ), + Padding( + padding: EdgeInsets.all(16.r), + child: Column( + children: [ + _receiptRow('Booking ID', '#$_bookingId'), + SizedBox(height: 10.h), + Divider( + color: ColorConstants.outlineVariant, + height: 1, + ), + SizedBox(height: 10.h), + _receiptRow('Tracking ID', _trackingId), + SizedBox(height: 10.h), + Divider( + color: ColorConstants.outlineVariant, + height: 1, + ), + SizedBox(height: 10.h), + _receiptRow('Customer', _customerName), + SizedBox(height: 10.h), + Divider( + color: ColorConstants.outlineVariant, + height: 1, + ), + SizedBox(height: 10.h), + _receiptRow( + 'Amount Collected', + '₹${_totalAmount.toStringAsFixed(0)}', + valueColor: ColorConstants.success, + valueBold: true, + ), + ], + ), + ), + ], + ), + ), + + const Spacer(flex: 2), + + // Action Buttons + Column( + children: [ + // Next Booking Button + MilerButton( + label: 'Move to next booking', + onPressed: () => _completePayment(goToBookings: true), + ), + SizedBox(height: 12.h), + TextButton( + onPressed: () => _completePayment(goToBookings: false), + child: Text( + 'Return to Collection', + style: TextStyle( + fontSize: 14.sp, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + SizedBox(height: 16.h), + ], + ), + ), + ), + ), + ); + } + + Widget _receiptRow( + String label, + String value, { + Color? valueColor, + bool valueBold = false, + }) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13.sp, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + Text( + value, + style: TextStyle( + fontSize: 14.sp, + fontWeight: valueBold ? FontWeight.w700 : FontWeight.w600, + color: valueColor ?? ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ); + } + + // ────────────────────────────── PAYMENT VIEW ───────────────────────────── + Widget _buildPaymentView() { + return Scaffold( + backgroundColor: ColorConstants.surface, + body: Column( + children: [ + // ── Premium Gradient Header ────────────────────────────────── + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + ColorConstants.primary, + ColorConstants.primaryContainer, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: SafeArea( + bottom: false, + child: Column( + children: [ + // App bar row + Padding( + padding: EdgeInsets.symmetric( + horizontal: 16.w, + vertical: 8.h, + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + width: 40.w, + height: 40.w, + decoration: BoxDecoration( + color: ColorConstants.pureSurface.withValues( + alpha: 0.15, + ), + shape: BoxShape.circle, + ), + child: Icon( + Icons.arrow_back_ios_new_rounded, + color: Colors.white, + size: 16.sp, + ), + ), + ), + SizedBox(width: 12.w), + Text( + 'Payment Collection', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w800, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + + // Amount display + Padding( + padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 24.h), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Amount to Collect', + style: TextStyle( + fontSize: 12.sp, + color: Colors.white.withValues(alpha: 0.75), + fontFamily: FontConstants.fontFamily, + letterSpacing: 0.3, + ), + ), + SizedBox(height: 4.h), + Text( + '₹${_totalAmount.toStringAsFixed(0)}', + style: TextStyle( + fontSize: 36.sp, + fontWeight: FontWeight.w900, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + letterSpacing: -1, + ), + ), + ], + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: 12.w, + vertical: 8.h, + ), + decoration: BoxDecoration( + color: ColorConstants.pureSurface.withValues( + alpha: 0.15, + ), + borderRadius: BorderRadius.circular( + DesignConstants.radiusLg, + ), + border: Border.all( + color: Colors.white.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '#$_orderId', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w700, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + Text( + _customerName, + style: TextStyle( + fontSize: 11.sp, + color: Colors.white.withValues(alpha: 0.8), + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + + // ── Scrollable body ────────────────────────────────────────── + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.all(16.r), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 4.h), + + // ── Booking Summary Card ────────────────────────────── + _sectionLabel('Booking Summary'), + SizedBox(height: 10.h), + Container( + width: double.infinity, + padding: EdgeInsets.all(16.r), + decoration: _cardDecoration(), + child: Column( + children: [ + _summaryRow('Customer', _customerName), + _divider(), + _summaryRow('Booking', '#$_orderId'), + _divider(), + _summaryRow('Weight', '$_weight kg'), + if (_description.isNotEmpty) ...[ + _divider(), + _summaryRow('Item', _description), + ], + _divider(), + _summaryRow( + 'Collection Charge', + '₹${_pickupCharge.toStringAsFixed(2)}', + isBold: true, + ), + Padding( + padding: EdgeInsets.symmetric(vertical: 8.h), + child: Container( + height: 1, + color: ColorConstants.primary.withValues( + alpha: 0.3, + ), + ), + ), + _summaryRow( + 'Total', + '₹${_totalAmount.toStringAsFixed(2)}', + isBold: true, + isTotal: true, + ), + ], + ), + ), + + SizedBox(height: 20.h), + + // ── Payment Method ────────────────────────────────── + _sectionLabel('Payment Method'), + SizedBox(height: 10.h), + + _buildPaymentOption( + Icons.money_rounded, + 'Cash', + 'Pay with cash on collection', + 'cash', + ColorConstants.success, + ), + SizedBox(height: 10.h), + _buildPaymentOption( + Icons.qr_code_scanner_rounded, + 'QR / UPI', + 'Scan QR code to pay', + 'upi', + ColorConstants.upiPurple, + ), + SizedBox(height: 10.h), + _buildPaymentOption( + Icons.credit_card_rounded, + 'Card', + 'Swipe / tap card terminal', + 'card', + ColorConstants.tertiary, + ), + + SizedBox(height: 28.h), + + // ── Slide to Collect Button ───────────────────────── + _isProcessing + ? Container( + height: ButtonSizes.primary, + decoration: BoxDecoration( + color: ColorConstants.primary, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + ), + ) + : ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: Container( + color: ColorConstants.primary, + child: SliderButton( + properties: SliderButtonProperties( + radius: 24, + height: ButtonSizes.primary, + buttonSize: 48, + width: MediaQuery.of(context).size.width - 32.w, + backgroundColor: Colors.transparent, + dismissThresholds: 0.85, + action: () async { + debugPrint( + '[PAYMENT_SLIDER] Action called, processing payment', + ); + await _processPayment(); + debugPrint( + '[PAYMENT_SLIDER] _processPayment completed, returning true', + ); + return true; + }, + label: Text( + 'Slide to Collect ₹${_totalAmount.toStringAsFixed(0)}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + icon: Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + shape: BoxShape.circle, + ), + child: Icon( + Icons.payments_rounded, + size: 22.sp, + color: ColorConstants.primary, + ), + ), + ), + ), + ), + ), + + SizedBox(height: 10.h), + Center( + child: Text( + 'Collect from customer before starting pickup', + style: TextStyle( + fontSize: 12.sp, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(height: 20.h), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _sectionLabel(String label) { + return Text( + label, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + letterSpacing: 0.5, + ), + ); + } + + Widget _buildPaymentOption( + IconData icon, + String title, + String subtitle, + String value, + Color accentColor, + ) { + final isSelected = _paymentMethod == value; + return GestureDetector( + onTap: () => setState(() => _paymentMethod = value), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: double.infinity, + padding: EdgeInsets.all(14.r), + decoration: BoxDecoration( + color: isSelected + ? accentColor.withValues(alpha: 0.06) + : ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all( + color: isSelected ? accentColor : ColorConstants.outlineVariant, + width: isSelected ? 2 : 1, + ), + boxShadow: isSelected + ? [ + BoxShadow( + color: accentColor.withValues(alpha: 0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ] + : [], + ), + child: Row( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: EdgeInsets.all(10.r), + decoration: BoxDecoration( + color: isSelected + ? accentColor.withValues(alpha: 0.12) + : const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Icon( + icon, + color: isSelected + ? accentColor + : ColorConstants.onSurfaceVariant, + size: 22.sp, + ), + ), + SizedBox(width: 14.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + Text( + subtitle, + style: TextStyle( + fontSize: 12.sp, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 22.w, + height: 22.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? accentColor : Colors.transparent, + border: Border.all( + color: isSelected ? accentColor : const Color(0xFFD1D5DB), + width: isSelected ? 0 : 2, + ), + ), + child: isSelected + ? Icon(Icons.check_rounded, color: Colors.white, size: 14.sp) + : null, + ), + ], + ), + ), + ); + } + + Widget _summaryRow( + String label, + String value, { + bool isBold = false, + bool isTotal = false, + }) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 5.h), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: isTotal ? 15.sp : 13.sp, + color: ColorConstants.onSurfaceVariant, + fontWeight: isBold ? FontWeight.w600 : FontWeight.normal, + fontFamily: FontConstants.fontFamily, + ), + ), + Text( + value, + style: TextStyle( + fontSize: isTotal ? 17.sp : 14.sp, + fontWeight: isTotal + ? FontWeight.w800 + : (isBold ? FontWeight.w700 : FontWeight.w500), + color: isTotal + ? ColorConstants.primary + : ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } + + Widget _divider() => Padding( + padding: EdgeInsets.symmetric(vertical: 2.h), + child: Divider(color: ColorConstants.outlineVariant, height: 1), + ); + + BoxDecoration _cardDecoration() { + return BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: ColorConstants.outlineVariant), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ); + } +} diff --git a/lib/views/Dashboard/pickups/pickup_verify.dart b/lib/views/Dashboard/pickups/pickup_verify.dart new file mode 100644 index 0000000..8e698f4 --- /dev/null +++ b/lib/views/Dashboard/pickups/pickup_verify.dart @@ -0,0 +1,908 @@ +import 'dart:io'; +import 'package:flutter/material.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'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.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'; + +class PickupVerificationPage extends StatefulWidget { + final Map pickup; + const PickupVerificationPage({super.key, required this.pickup}); + + @override + State createState() => _PickupVerificationPageState(); +} + +class _PickupVerificationPageState extends State + with TickerProviderStateMixin { + File? _parcelImage; + final ImagePicker _picker = ImagePicker(); + bool _isConfirming = false; + bool _photoVerified = false; + + // Form controllers + final TextEditingController _weightController = TextEditingController(); + final TextEditingController _descriptionController = TextEditingController(); + final TextEditingController _otpController = TextEditingController(); + final TextEditingController _dropAddressController = TextEditingController(); + + late final String _customerName; + late final String _pickupAddress; + late final String _orderId; + late final String _dropAddressDefault; + + // Delivery stops (Amazon/Flipkart-style) verify differently: proof is + // OTP + photo (prepaid, no weight/signature). Pickups keep the full form. + late final bool _isDelivery; + + late AnimationController _fadeController; + late Animation _fadeAnim; + + // Red Theme Colors + static Color get _primaryRed => ColorConstants.primary; + static Color get _darkRed => ColorConstants.primary; + static Color get _lightRed => ColorConstants.errorLight; + static Color get _successGreen => ColorConstants.acceptGreen; + + @override + void initState() { + super.initState(); + final d = widget.pickup; + _customerName = (d['PickupCustomer'] ?? d['pickupcustomer'] ?? 'Customer') + .toString(); + _pickupAddress = + (d['pickupaddress'] ?? d['PickupAddress'] ?? 'Pickup Location') + .toString(); + _orderId = (d['orderid'] ?? '').toString(); + _dropAddressDefault = + (d['DropAddress'] ?? d['dropaddress'] ?? 'Drop Location').toString(); + _dropAddressController.text = _dropAddressDefault; + _isDelivery = stopKindOf(d).isDelivery; + + _fadeController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + _fadeAnim = CurvedAnimation(parent: _fadeController, curve: Curves.easeOut); + _fadeController.forward(); + } + + @override + void dispose() { + _weightController.dispose(); + _descriptionController.dispose(); + _otpController.dispose(); + _dropAddressController.dispose(); + _fadeController.dispose(); + super.dispose(); + } + + Future _takeParcelPhoto() async { + try { + final XFile? photo = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 85, + ); + if (photo != null && mounted) { + setState(() { + _parcelImage = File(photo.path); + _photoVerified = true; + }); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Failed to capture photo')), + ); + } + } + } + + // Required proof per stop is kept light: Photo + Code (pickup) / Photo + OTP + // (delivery). Weight is optional — no keyboard entry needed at every doorstep. + bool get _isFormValid => _isDelivery + ? (_photoVerified && _otpController.text.trim().isNotEmpty) + : (_photoVerified && _otpController.text.trim().isNotEmpty); + + void _confirmPickup() { + if (!_isFormValid) { + String msg = 'Please complete all required fields'; + if (!_photoVerified) { + msg = _isDelivery + ? 'Take a photo of the delivered parcel first' + : 'Take a photo of the parcel first'; + } else if (_isDelivery && _otpController.text.trim().isEmpty) { + msg = 'Enter the delivery OTP from the customer'; + } else if (!_isDelivery && _otpController.text.trim().isEmpty) { + msg = 'Enter the pickup code from the customer'; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(msg), + backgroundColor: _primaryRed, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + ), + ); + return; + } + + setState(() => _isConfirming = true); + + final verified = { + 'verified': true, + 'parcelImage': _parcelImage?.path, + 'weight': _weightController.text.trim(), + 'description': _descriptionController.text.trim(), + 'dropAddress': _dropAddressController.text.trim(), + 'otp': _otpController.text.trim(), + }; + + debugPrint( + '[PICKUP_VERIFY] Confirming pickup and popping with verified data', + ); + + // Pop with verified data + Navigator.pop(context, verified); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ColorConstants.daylightSurface, + appBar: _buildAppBar(), + body: FadeTransition( + opacity: _fadeAnim, + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 32.h), + child: SizedBox( + width: MediaQuery.of(context).size.width - 32.w, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildProgressSteps(), + SizedBox(height: 20.h), + + _buildCustomerCard(), + SizedBox(height: 20.h), + + _buildSectionHeader( + icon: Icons.camera_alt_rounded, + title: _isDelivery ? 'Delivery Photo' : 'Parcel Photo', + badge: 'Required', + isComplete: _photoVerified, + ), + SizedBox(height: 10.h), + _buildPhotoCapture(), + SizedBox(height: 20.h), + + // Weight is pickup-only. Deliveries are prepaid and proven by + // OTP + photo. Signature was dropped — photo is enough proof for + // a first-mile pickup, so it was pure friction on every stop. + if (!_isDelivery) ...[ + _buildSectionHeader( + icon: Icons.inventory_2_rounded, + title: 'Parcel Details (Optional)', + isComplete: _weightController.text.isNotEmpty, + ), + SizedBox(height: 10.h), + _buildParcelDetailsCard(), + SizedBox(height: 20.h), + ], + + _buildSectionHeader( + icon: Icons.lock_outline_rounded, + title: _isDelivery ? 'Delivery OTP' : 'Pickup Code', + badge: 'Required', + isComplete: _otpController.text.isNotEmpty, + ), + SizedBox(height: 10.h), + _buildCard([ + TextField( + controller: _otpController, + keyboardType: TextInputType.number, + maxLength: 6, + onChanged: (_) => setState(() {}), + decoration: + _inputDecoration( + _isDelivery + ? 'Enter OTP from customer' + : 'Enter code from customer', + ).copyWith( + counterText: '', + prefixIcon: Icon( + Icons.lock_outline_rounded, + color: ColorConstants.secondaryText, + size: 20.sp, + ), + ), + style: TextStyle( + fontSize: 15.sp, + fontFamily: FontConstants.fontFamily, + ), + ), + ]), + SizedBox(height: 32.h), + + _buildConfirmSlider(), + SizedBox(height: 20.h), + ], + ), + ), + ), + ), + ); + } + + AppBar _buildAppBar() { + return AppBar( + backgroundColor: ColorConstants.pureSurface, + elevation: 0, + centerTitle: false, + surfaceTintColor: Colors.transparent, + // 56 → 68, the same air the tab bars gained. Height rather than a title + // inset so the back arrow moves down with the heading instead of the two + // drifting apart. + toolbarHeight: 68, + leading: IconButton( + tooltip: 'Back', + icon: Icon( + Icons.arrow_back_ios_new_rounded, + color: ColorConstants.onSurface, + size: 20.sp, + ), + onPressed: () => Navigator.pop(context), + ), + title: Row( + children: [ + // No brand mark. It is gone from every bar in the app: on a pushed + // sub-page the back arrow beside it already says where the rider is, + // and the logo was repeating what the screen he came from said. + Text( + _isDelivery ? 'Verify Delivery' : 'Verify Pickup', + style: TextStyle( + fontSize: 21.sp, + color: ColorConstants.onSurface, + fontWeight: FontWeight.w800, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(height: 1, color: ColorConstants.dividerColor), + ), + ); + } + + Widget _buildProgressSteps() { + final steps = _isDelivery + ? [ + {'label': 'Photo', 'done': _photoVerified}, + {'label': 'OTP', 'done': _otpController.text.isNotEmpty}, + ] + : [ + {'label': 'Photo', 'done': _photoVerified}, + {'label': 'Code', 'done': _otpController.text.isNotEmpty}, + ]; + + return Container( + padding: EdgeInsets.symmetric(horizontal: 4.w, vertical: 12.h), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: ColorConstants.dividerColor), + ), + child: Row( + children: List.generate(steps.length * 2 - 1, (i) { + if (i.isOdd) { + final leftDone = steps[i ~/ 2]['done'] as bool; + return Expanded( + child: Container( + height: 2.h, + margin: EdgeInsets.symmetric(horizontal: 4.w), + decoration: BoxDecoration( + color: leftDone ? _successGreen : ColorConstants.dividerColor, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + ), + ); + } + final idx = i ~/ 2; + final step = steps[idx]; + final done = step['done'] as bool; + final label = step['label'] as String; + return Expanded( + child: Column( + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: 32.w, + height: 32.w, + decoration: BoxDecoration( + color: done ? _successGreen : ColorConstants.neutralLight, + shape: BoxShape.circle, + border: Border.all( + color: done ? _successGreen : ColorConstants.dividerColor, + width: 2, + ), + ), + child: Icon( + done ? Icons.check_rounded : Icons.circle_outlined, + color: done ? Colors.white : ColorConstants.secondaryText, + size: 16.sp, + ), + ), + SizedBox(height: 4.h), + Text( + label, + style: TextStyle( + fontSize: 10.sp, + fontWeight: done ? FontWeight.w700 : FontWeight.w500, + color: done + ? _successGreen + : ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + }), + ), + ); + } + + Widget _buildCustomerCard() { + final initials = _customerName.trim().isNotEmpty + ? _customerName + .trim() + .split(' ') + .map((w) => w.isNotEmpty ? w[0] : '') + .take(2) + .join() + .toUpperCase() + : 'C'; + + return Container( + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [_primaryRed, _darkRed], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + boxShadow: [ + BoxShadow( + color: _primaryRed.withValues(alpha: 0.25), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 44.w, + height: 44.w, + decoration: BoxDecoration( + color: ColorConstants.pureSurface.withValues(alpha: 0.2), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: 0.4), + ), + ), + child: Center( + child: Text( + initials, + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w800, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _customerName, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w800, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + Text( + 'Booking #$_orderId', + style: TextStyle( + fontSize: 12.sp, + color: Colors.white.withValues(alpha: 0.8), + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + SizedBox(height: 12.h), + Container( + height: 1, + color: ColorConstants.pureSurface.withValues(alpha: 0.2), + ), + SizedBox(height: 12.h), + _buildLocationRow( + Icons.location_on_rounded, + _pickupAddress, + _isDelivery ? 'DELIVERY' : 'PICKUP', + ColorConstants.warning.withValues(alpha: 0.40), + ), + ], + ), + ); + } + + Widget _buildLocationRow( + IconData icon, + String address, + String label, + Color accentColor, + ) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: EdgeInsets.all(5.r), + decoration: BoxDecoration( + color: ColorConstants.pureSurface.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Icon(icon, size: 14.sp, color: accentColor), + ), + SizedBox(width: 10.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 9.sp, + fontWeight: FontWeight.w700, + color: accentColor, + letterSpacing: 0.8, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 1.h), + Text( + address, + style: TextStyle( + fontSize: 12.sp, + color: Colors.white.withValues(alpha: 0.9), + fontFamily: FontConstants.fontFamily, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + } + + Widget _buildSectionHeader({ + required IconData icon, + required String title, + String? badge, + bool isComplete = false, + }) { + return Row( + children: [ + Container( + padding: EdgeInsets.all(7.r), + decoration: BoxDecoration( + color: isComplete + ? ColorConstants.successLight + : ColorConstants.neutralLight, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Icon( + isComplete ? Icons.check_circle_rounded : icon, + size: 16.sp, + color: isComplete ? _successGreen : ColorConstants.onSurfaceVariant, + ), + ), + SizedBox(width: 10.w), + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + if (badge != null) + Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), + decoration: BoxDecoration( + color: _lightRed, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Text( + badge, + style: TextStyle( + fontSize: 10.sp, + fontWeight: FontWeight.w700, + color: _primaryRed, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ); + } + + Widget _buildPhotoCapture() { + return GestureDetector( + onTap: _takeParcelPhoto, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: double.infinity, + height: 180.h, + decoration: BoxDecoration( + color: _photoVerified + ? Colors.transparent + : ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all( + color: _photoVerified ? _successGreen : ColorConstants.borderSubtle, + width: _photoVerified ? 2 : 1, + ), + boxShadow: _photoVerified + ? [ + BoxShadow( + color: _successGreen.withValues(alpha: 0.1), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ] + : [], + ), + child: _parcelImage != null + ? ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: Stack( + fit: StackFit.expand, + children: [ + Image.file(_parcelImage!, fit: BoxFit.cover), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 14.w, + vertical: 10.h, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withValues(alpha: 0.6), + Colors.transparent, + ], + ), + ), + child: Row( + children: [ + Icon( + Icons.check_circle_rounded, + color: Colors.white, + size: 16.sp, + ), + SizedBox(width: 6.w), + Text( + 'Photo captured — tap to retake', + style: TextStyle( + color: Colors.white, + fontSize: 12.sp, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + Positioned( + top: 10.h, + right: 10.w, + child: Container( + padding: EdgeInsets.all(6.r), + decoration: BoxDecoration( + color: _successGreen, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_rounded, + color: Colors.white, + size: 14.sp, + ), + ), + ), + ], + ), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: EdgeInsets.all(18.r), + decoration: BoxDecoration( + color: ColorConstants.neutralLight, + shape: BoxShape.circle, + ), + child: Icon( + Icons.camera_alt_rounded, + color: ColorConstants.secondaryText, + size: 36.sp, + ), + ), + SizedBox(height: 10.h), + Text( + 'Tap to take parcel photo', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 4.h), + Text( + 'Required for pickup confirmation', + style: TextStyle( + fontSize: 11.sp, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ); + } + + Widget _buildParcelDetailsCard() { + return _buildCard([ + _fieldLabel('Weight (kg)'), + SizedBox(height: 6.h), + TextField( + controller: _weightController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (_) => setState(() {}), + decoration: _inputDecoration('e.g. 2.5').copyWith( + suffixText: 'kg', + suffixStyle: TextStyle( + fontSize: 13.sp, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + style: TextStyle(fontSize: 15.sp, fontFamily: FontConstants.fontFamily), + ), + SizedBox(height: 16.h), + + _fieldLabel('Description (Optional)'), + SizedBox(height: 6.h), + TextField( + controller: _descriptionController, + maxLines: 3, + decoration: _inputDecoration('Item description, fragile, etc.'), + style: TextStyle(fontSize: 15.sp, fontFamily: FontConstants.fontFamily), + ), + SizedBox(height: 16.h), + ]); + } + + Widget _fieldLabel(String label, {bool required = false}) { + return Row( + children: [ + Text( + label, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + if (required) ...[ + SizedBox(width: 4.w), + Text( + '*', + style: TextStyle( + fontSize: 13.sp, + color: _primaryRed, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ], + ); + } + + Widget _buildConfirmSlider() { + if (_isConfirming) { + return Container( + height: ButtonSizes.primary, + decoration: BoxDecoration( + color: ColorConstants.primary, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + ), + ); + } + + if (!_isFormValid) { + return Container( + height: ButtonSizes.primary, + decoration: BoxDecoration( + color: ColorConstants.dividerColor, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.lock_outline_rounded, + color: ColorConstants.secondaryText, + size: 18.sp, + ), + SizedBox(width: 8.w), + Text( + !_photoVerified + ? 'Take a photo to continue' + : _otpController.text.isEmpty + ? (_isDelivery + ? 'Enter OTP to continue' + : 'Enter pickup code to continue') + : 'Complete all fields to continue', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } + + return SliderButton( + properties: SliderButtonProperties( + height: ButtonSizes.primary, + buttonSize: 48, + width: MediaQuery.of(context).size.width - 32.w, + backgroundColor: ColorConstants.primary, + dismissThresholds: 0.85, + action: () async { + debugPrint( + '[PICKUP_VERIFY_SLIDER] Action called, form valid: $_isFormValid', + ); + if (!_isFormValid) { + debugPrint( + '[PICKUP_VERIFY_SLIDER] Form not valid, returning false', + ); + return false; + } + _confirmPickup(); + debugPrint( + '[PICKUP_VERIFY_SLIDER] _confirmPickup called, returning true', + ); + return true; + }, + label: Text( + 'Slide to Confirm Pickup', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + icon: Container( + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + shape: BoxShape.circle, + ), + child: Icon( + Icons.check_rounded, + size: 22.sp, + color: ColorConstants.primary, + ), + ), + ), + ); + } + + Widget _buildCard(List children) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: ColorConstants.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ); + } + + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: TextStyle( + fontSize: 14.sp, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + filled: true, + fillColor: ColorConstants.daylightSurface, + contentPadding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide(color: ColorConstants.borderStrong), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide(color: ColorConstants.borderStrong), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide(color: ColorConstants.primary, width: 1.5), + ), + ); + } +} diff --git a/lib/views/Dashboard/pickups/pickups.dart b/lib/views/Dashboard/pickups/pickups.dart new file mode 100644 index 0000000..dfb255a --- /dev/null +++ b/lib/views/Dashboard/pickups/pickups.dart @@ -0,0 +1,2506 @@ +// 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/foundation.dart' show kDebugMode; +import 'package:flutter/material.dart'; +import 'package:miler/views/helpers/constants/design_constants.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'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/views/helpers/widgets/page_transitions.dart'; +import 'package:miler/views/Dashboard/pickups/stop_type.dart'; +import 'package:miler/views/Dashboard/pickups/route_metrics.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +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: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: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/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'; + +part 'card.dart'; +part 'map.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';` — REMOVED FROM THE BUILD. +// +// 1,070 lines of a Cart screen that nothing in the app ever instantiated: +// `Cartpage` had no route, no tab and no call site. It compiled into every +// release, carried its own 30-second poller and its own copy of the stop card, +// and — because it was never on screen — quietly rotted out of step with the +// Bookings list it duplicated. +// +// The file is still on disk. Delete it once this repo is under version control +// and you can get it back; until then it is simply out of the binary. + +/// 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 MyPickups extends StatefulWidget { + const MyPickups({super.key}); + + @override + State createState() => _MyPickupsState(); + + /// Forgets a local skip, so the stop stops being re-stamped 'skipped' on the + /// next poll. + /// + /// Keyed the way this screen keys everything: pickup id when there is one, + /// order id otherwise. + static void forgetSkip({String pickupId = '', String orderId = ''}) { + final key = pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId'; + _MyPickupsState._forgetSkip(key); + } + + /// Puts a skipped stop back into the working queue. + /// + /// A skip parks a stop for a return visit, so it waits on **Activity** — the + /// record of what has happened today — rather than among the live work on + /// Bookings. Resuming is an accept on the wire, the same call the accept bar + /// makes, plus clearing the skip in both places that hold one: + /// + /// • [_skippedOrdersCache], or the 3s poller would re-stamp 'skipped' onto + /// the stop within seconds and the resume would silently undo itself; + /// • the persistent store, which is what Home and Activity read. + /// + /// Optimistic, like every other decision in this app: the local record is + /// written whatever the network says, and the poll catches up. Returns false + /// only when there is no order id to act on. + /// + /// Lives here rather than on the screen that shows the control because two + /// screens have now owned that control in turn — Home, then Activity — and + /// the logic moved with it both times, cache-clearing included. + static Future resumeSkippedStop(Map stop) async { + final orderId = (stop['orderid'] ?? '').toString(); + if (orderId.isEmpty) return false; + + forgetSkip(pickupId: (stop['pickupid'] ?? '').toString(), orderId: orderId); + + final dc = Get.isRegistered() + ? Get.find() + : Get.put(PickupsController(), permanent: true); + + String lat = '0'; + String lng = '0'; + try { + final pos = + await Geolocator.getLastKnownPosition() ?? + await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.medium, + ), + ); + lat = pos.latitude.toStringAsFixed(6); + lng = pos.longitude.toStringAsFixed(6); + } catch (e) { + debugPrint('[RESUME] No location for $orderId: $e'); + } + + try { + await dc.updateAcceptedStatus( + pickupId: int.tryParse('${stop['pickupid'] ?? 0}') ?? 0, + orderHeaderId: int.tryParse('${stop['orderheaderid'] ?? 0}') ?? 0, + ridersLat: lat, + ridersLng: lng, + ); + } catch (e) { + debugPrint('[RESUME] $orderId status call failed: $e'); + } + + final resumed = Map.from(stop) + ..['orderstatus'] = 'accepted'; + await removeSkippedBookings([orderId]); + await addAcceptedBookings([resumed]); + stop['orderstatus'] = 'accepted'; + + dc.triggerRefresh(); + return true; + } + + // ✅ Public static method to navigate to pickup map screen from outside (e.g., home page) + static Future navigateToPickupMap( + BuildContext context, + Map pickup, + ) async { + await openScreen( + context, + _PickupMapScreen( + pickup: pickup, + parentState: null, // No parent state when called from outside + ), + swipeToGoBack: false, + ); + } +} + +class _MyPickupsState extends State + with AutomaticKeepAliveClientMixin, WidgetsBindingObserver { + final PickupProvider _provider = PickupProvider(); + final CreatePickupLogProvider _pickupLogProvider = CreatePickupLogProvider(); + List> _picked = >[]; + List> _activePickups = + >[]; // Track active pickup for banner + + StreamSubscription? _pollerSubscription; + + /// What [_syncPolling] last set, so an unchanged interval is a no-op. + Duration? _currentPollInterval; + + /// False while the app is backgrounded — see [pollInterval]. + bool _pollForeground = true; + + /// Whether the rider is on duty. Drives the idle poll rate. + bool _onDuty = true; + + /// Mirrors the wakelock so it is only toggled on a real change. + bool _wakelockHeld = false; + + /// Set when a fetch fails with nothing to fall back on. An empty queue after + /// a network error used to render as the "You're all caught up" celebration — + /// the app congratulating the rider on work it had failed to ask for. + bool _fetchFailed = false; + + bool _fetching = false; + + /// False until the first fetch has come back, win or lose. + /// + /// Without it this screen answered "how many bookings do I have?" before it + /// had asked: an empty `_picked` renders the "You're all caught up" Lottie, + /// so every cold open of the Bookings tab congratulated the rider on an empty + /// queue for as long as the network took, then replaced it with a list of + /// stops. Empty and not-yet-known are different states and only one of them + /// is good news. + bool _firstLoadDone = false; + Position? _currentLocation; + final Map _pickupTimers = {}; + final Map> _pickupBasePayload = + >{}; + // Preserve original step numbers so they don't change when pickup are completed + final Map _preservedStepNumbers = {}; + String? _activePickupOrderId; + // Order keys the rider just finished (picked up / cancelled). We keep them + // here so the 3s poller can't resurrect a completed stop before the backend + // reflects it — otherwise a done pickup keeps re-appearing as "active" and + // blocks the next one ("Complete current stop first"). + final Set _completedOrderKeys = {}; + // Cache for skipped bookings. + // + // Static, and reachable from outside this State, because a skip is now + // visible on two screens: Bookings records it, and Home lists the skipped + // stop until the rider resumes it. Resuming happens on Home — so Home has to + // be able to clear the entry, or this cache would re-stamp 'skipped' onto the + // stop on the very next poll and the resume would silently undo itself. The + // Bookings tab lives inside an IndexedStack and is never disposed, so a + // per-State map really would outlive the resume. + static final Map _skippedOrdersCache = {}; + static final Map _skippedOrderTimestamps = {}; + + static void _forgetSkip(String key) { + _skippedOrdersCache.remove(key); + _skippedOrderTimestamps.remove(key); + } + + 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 Booking) { + final dynamic raw = Booking['step'] ?? Booking['Step']; + final int step = raw == null + ? 0 + : (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0); + return step; + } + + String _getOrderKey(Map Booking) { + // Use pickupId as primary key, fallback to orderId + final pickupId = (Booking['pickupid'] ?? '').toString(); + final orderId = (Booking['orderid'] ?? '').toString(); + return pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId'; + } + + int _getPreservedOrCurrentStep(Map Booking) { + final orderKey = _getOrderKey(Booking); + // If we have a preserved step number, use it; otherwise use current API step + if (_preservedStepNumbers.containsKey(orderKey)) { + return _preservedStepNumbers[orderKey]!; + } + final currentStep = _getStepNumber(Booking); + // Preserve the step number if it's valid (> 0) + if (currentStep > 0) { + _preservedStepNumbers[orderKey] = currentStep; + } + return currentStep; + } + + /// Today's finished stops, newest last, for the rail. + /// + /// Refreshed with every fetch. See [_railStops] for why the rail needs them. + List> _completedToday = >[]; + + /// What the rail draws: the whole run, finished stops included. + /// + /// ── Why this is not [_visibleStops] ── + /// + /// It was, and that is why the rail never moved. `_visibleStops` is the list + /// of stops still to *work*, and a completed stop is removed from it the + /// instant it is confirmed — so the rail was handed a set in which nothing + /// could ever be `done`. Every node stayed grey, the scooter sat on the first + /// one all day, and finishing a stop made the rail one node SHORTER instead + /// of one node greener: the rider's only measure of progress went backwards + /// as he made it. + /// + /// The completed stops come from the same store the Activity tab reads, so + /// the two agree about what was finished, and they lead because they were + /// done first. The open ones follow in the hub's order. + List> get _railStops { + final open = _visibleStops; + if (_completedToday.isEmpty) return open; + + final openKeys = open.map(_getOrderKey).toSet(); + final done = _completedToday + .where((o) => !openKeys.contains(_getOrderKey(o))) + .toList() + .reversed // the store hands them back newest first + .toList(); + + return [...done, ...open]; + } + + /// Progress state for every stop on the accepted trip, in the admin's fixed + /// 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. + 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); + } + } + + // No stop is live: promote the first pending one so the rail always shows + // the rider where he is in the sequence. + if (!states.contains(StopProgress.current)) { + final next = states.indexOf(StopProgress.pending); + if (next >= 0) states[next] = StopProgress.current; + } + return states; + } + + /// Stage-by-stage counts from the last fetch. Debug only — surfaced on the + /// empty state so "no cards" reports a cause instead of a symptom. + String _fetchDiag = ''; + + /// Which of the day's trips each order belongs to, 1-based. + /// + /// ── Why this is computed from the whole day and not from this list ── + /// + /// Bookings shows only accepted stops, so grouping *its* list would number + /// whatever the rider happened to take as "Trip 1" — a miler who declined the + /// morning slot and took the afternoon one would be told he is riding Trip 1 + /// while the hub, Home and his own supervisor all call it Trip 2. The number + /// only means something against the day the hub assigned, so it is derived + /// from every stop the app knows about today — pending, accepted, finished — + /// before any of this screen's filtering. + /// + /// Rebuilt on each fetch, so a trip appearing mid-shift renumbers everywhere + /// at once rather than only here. + Map _tripNumberByOrderId = const {}; + + /// Groups every stop the app knows about today and hands back order id → trip + /// number. Deduped by order id first: the same stop arrives from up to three + /// sources per fetch, and counting it twice would not change its trip but + /// would waste the grouping's arithmetic on copies. + Map _numberTrips(List> allToday) { + final byId = >{}; + for (final s in allToday) { + final id = (s['orderid'] ?? '').toString(); + if (id.isEmpty) continue; + byId.putIfAbsent(id, () => s); + } + if (byId.isEmpty) return const {}; + + final trips = Trip.groupIntoTrips( + byId.values.toList(), + hubLat: _currentLocation?.latitude, + hubLng: _currentLocation?.longitude, + ); + + return { + for (var i = 0; i < trips.length; i++) + for (final id in trips[i].orderIds) id: i + 1, + }; + } + + /// The trip a stop belongs to, or null when the day's shape is not known yet. + int? _tripNumberOf(Map stop) => + _tripNumberByOrderId[(stop['orderid'] ?? '').toString()]; + + /// The trip the rider is riding right now: the one owning the stop he is on, + /// or the next one he will reach. Null when nothing is outstanding. + int? get _currentTripNumber { + final stops = _visibleStops; + if (stops.isEmpty) return null; + // The live stop first — it is the one he is standing at. + for (final s in stops) { + if (stopStatusOf(s).isActive) { + final n = _tripNumberOf(s); + if (n != null) return n; + } + } + for (final s in stops) { + final n = _tripNumberOf(s); + if (n != null) return n; + } + return null; + } + + /// Every trip number present in the outstanding queue, in order. More than one + /// means the rider is carrying work from two slots at once, which he is + /// entitled to know before he plans his afternoon. + List get _queuedTripNumbers { + final seen = {}; + for (final s in _visibleStops) { + final n = _tripNumberOf(s); + if (n != null) seen.add(n); + } + final list = seen.toList()..sort(); + return list; + } + + /// The accepted stops, grouped into the same trips Home shows. + /// + /// Still grouped, because grouping is what puts the stops in the hub's + /// intended riding order. Bookings no longer *shows* the grouping — see + /// [_visibleStops]. + List get _bookingTrips => Trip.groupIntoTrips( + _picked, + hubLat: _currentLocation?.latitude, + hubLng: _currentLocation?.longitude, + ); + + /// Every accepted stop, in riding order, as one continuous list. + /// + /// ── Why the Trip 1 · 2 · 3 tabs are gone from this screen ── + /// + /// They were here to mirror Home, and mirroring was the wrong goal. The two + /// screens answer different questions: + /// + /// • **Home is a decision screen.** The rider is choosing which of the day's + /// three trips to take, so the trips have to be separable — he accepts one + /// slot's route and declines another's. Tabs are right there. + /// + /// • **Bookings is a work screen.** Everything on it is already accepted; + /// the only question left is *which stop do I ride to next*. That question + /// has one answer across the whole day, and it does not care which slot the + /// stop was assigned in. + /// + /// The tabs actively got in the way of it. Finishing the last stop of Trip 1 + /// left the rider on a tab reading "Trip 1 is done" with his next stop hidden + /// behind a tap he had no reason to know he needed — the screen looked + /// finished while work remained. And a stop could be *invisible* on the screen + /// whose entire job is to show him his work, purely because a tab was closed. + /// + /// One list, in order, no hidden state. + List> get _visibleStops => [ + for (final trip in _bookingTrips) ...trip.stops, + ]; + + /// 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 + /// no longer a "this tab is empty but the others are not" case to explain, + /// because there are no tabs to be on the wrong one of. Either there is work + /// on this screen or the day is done. + /// + /// Must stay scrollable — it is the child of a `RefreshIndicator`, and + /// pull-to-refresh is the rider's way out of it. + Widget _buildTripTabEmptyState() { + return ListView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + padding: EdgeInsets.fromLTRB(24.w, 48.h, 24.w, 24.h), + children: [ + Center( + child: Container( + width: 62.w, + height: 62.w, + decoration: BoxDecoration( + color: ColorConstants.neutralLight, + shape: BoxShape.circle, + border: Border.all(color: ColorConstants.borderStrong), + ), + child: Icon( + Icons.check_circle_outline_rounded, + size: 28.sp, + color: ColorConstants.acceptGreen, + ), + ), + ), + SizedBox(height: 14.h), + Text( + 'All your stops are done', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 6.h), + Text( + 'Anything you accept on Home lands here. Pull down to refresh.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.5.sp, + height: 1.45, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + _fetchDiagLine(), + ], + ); + } + + /// The last fetch's stage counts, shown under an empty state in debug only. + /// Never compiled into release — [kDebugMode] is a const, so the whole widget + /// folds away. + Widget _fetchDiagLine() { + if (!kDebugMode || _fetchDiag.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: EdgeInsets.only(top: 18.h), + child: Text( + 'debug · $_fetchDiag\n' + '${_bookingTrips.length} trip(s) · showing ${_visibleStops.length}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10.sp, + height: 1.5, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ); + } + + Widget _buildTripRail() { + // The whole run, finished stops included — see [_railStops]. Drawn as long + // as *anything* happened today, so a rider who has just closed his last + // stop still sees the full green rail rather than the strip vanishing at + // the moment it finally reads 100%. + final railStops = _railStops; + if (railStops.isEmpty) return const SizedBox.shrink(); + + final states = _tripProgress(); + final trip = Trip.fromStops( + id: 'active', + stops: railStops, + hubLat: _currentLocation?.latitude, + hubLng: _currentLocation?.longitude, + ); + + // Time left is only the work still outstanding — counting finished stops + // would keep the estimate flat all day and make it useless. + var remaining = Duration.zero; + for (var i = 0; i < railStops.length && i < states.length; i++) { + if (states[i] == StopProgress.done) continue; + remaining += Trip.serviceTimeFor(railStops[i]); + remaining += trip.travelTimeToStop( + i, + hubLat: _currentLocation?.latitude, + hubLng: _currentLocation?.longitude, + ); + } + + // ── Name the trip he is riding ── + // + // The rail headline read "10:00 AM – 1:00 PM", or "Your route" when the + // backend sent no slot — neither of which answers the question the rider + // actually asks himself on this screen, which is *which of today's trips am + // I on*. He is given three, they are numbered on Home, the hub refers to + // them by number on the phone, and this screen was the one place in the + // flow that would not say. + // + // The number leads and the window follows it, because the number is the + // shared name and the window is the detail: "Trip 2 · 10:00 AM – 1:00 PM". + final tripNo = _currentTripNumber; + final window = trip.slotStart != null ? trip.slotLabel : null; + final label = tripNo == null + ? (window ?? 'Your route') + : window == null + ? 'Trip $tripNo' + : 'Trip $tripNo · $window'; + + return TripProgressRail( + progress: states, + slotLabel: label, + remaining: remaining > Duration.zero ? remaining : null, + ); + } + + 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['pickuplat']); + final double dLon = _parseD(m['droplon'] ?? m['pickuplong']); + + 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 Booking) { + if (_currentLocation == null) return double.infinity; + + final double dropLat = _parseD(Booking['droplat'] ?? Booking['pickuplat']); + final double dropLon = _parseD(Booking['droplon'] ?? Booking['pickuplong']); + + if (dropLat == 0 || dropLon == 0) return double.infinity; + + return _haversineKm( + _currentLocation!.latitude, + _currentLocation!.longitude, + dropLat, + dropLon, + ); + } + + 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)), + ); + + if (_currentLocation != null && ordersWithoutStep.isNotEmpty) { + ordersWithoutStep.sort((a, b) { + final distA = _calculateDistanceFromCurrent(a); + final distB = _calculateDistanceFromCurrent(b); + return distA.compareTo(distB); + }); + } + + return [...ordersWithStep, ...ordersWithoutStep]; + } + + int _getDisplayStepNumber(List> allOrders, int index) { + final Booking = allOrders[index]; + // Use preserved step number if available, otherwise get current step + final step = _getPreservedOrCurrentStep(Booking); + + if (step > 0) return step; + + // For bookings without step numbers, calculate based on bookings 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); + _loadDutyState(); + + // Listen for external refresh triggers + try { + final dc = Get.isRegistered() + ? Get.find() + : Get.put(PickupsController()); + + ever(dc.refreshTrigger, (_) { + if (mounted) { + debugPrint( + '[MYPICKUPS] External trigger -> Refreshing picked bookings', + ); + _fetchPicked(force: true); + } + }); + } catch (_) {} + + // ── Re-read whenever this tab comes forward ── + // + // The shell keeps all four tabs alive in an `IndexedStack`, so arriving + // here is not a build — it is an index change, and nothing about it asks + // this screen for fresh data. The poller does eventually, up to three + // seconds later, which is exactly long enough for a rider who has just + // accepted a stop on Home to arrive, find it missing, and conclude the + // accept failed. Activity carries the same listener for the same reason. + BottomPage.currentTab.addListener(_onTabChanged); + + // Run these in parallel + _restoreSkips(); + _restoreActivePickup(); + _initializeLocation(); // Don't block + _fetchPicked(); + _startPollingStream(); + } + + /// This screen's slot in the shell. See [BottomPage.goToTab]. + static const int _bookingsTabIndex = 1; + + void _onTabChanged() { + if (BottomPage.currentTab.value == _bookingsTabIndex && mounted) { + _fetchPicked(force: true); + } + } + + /// Re-loads today's skips into [_skippedOrdersCache] after a cold start. + /// + /// The cache is static but not persistent, so killing the app used to lose + /// every skip taken that shift: the stop came back from the queue with + /// whatever status the backend last recorded and dropped straight back into + /// the working list, ahead of stops the rider had not skipped. The store that + /// Activity reads is the same one, so both screens now agree after a restart. + Future _restoreSkips() async { + try { + final skipped = await getSkippedBookings(); + for (final b in skipped) { + final key = _getOrderKey(b); + _skippedOrdersCache[key] = (b['skipreason'] ?? '').toString(); + _skippedOrderTimestamps[key] = + DateTime.tryParse( + (b['skippedat'] ?? '').toString(), + )?.millisecondsSinceEpoch ?? + DateTime.now().millisecondsSinceEpoch; + } + if (skipped.isNotEmpty) { + debugPrint('[MYPICKUPS] Restored ${skipped.length} skip(s) from store'); + } + } catch (e) { + debugPrint('[MYPICKUPS] Could not restore skips: $e'); + } + } + + Future _restoreActivePickup() async { + try { + final prefs = await SharedPreferences.getInstance(); + final savedActiveId = prefs.getString('active_pickup_order_id'); + if (savedActiveId != null && savedActiveId.isNotEmpty) { + _activePickupOrderId = savedActiveId; + debugPrint( + '[MYPICKUPS] Restored active pickup orderId: $savedActiveId', + ); + } + } catch (e) { + debugPrint('[MYPICKUPS] Error restoring active pickup: $e'); + } + } + + /// Restarts the poller at the speed the rider's current situation deserves. + /// + /// Cheap to call: if the interval has not changed, the running stream is left + /// alone rather than torn down and rebuilt (which would reset the clock and, + /// called from a fetch handler, could starve the poll entirely). + void _syncPolling() { + if (!_pollForeground) { + _pollerSubscription?.cancel(); + _pollerSubscription = null; + _currentPollInterval = null; + return; + } + + final Duration interval = pollInterval( + hasLiveWork: _activePickups.isNotEmpty, + onDuty: _onDuty, + ); + if (_pollerSubscription != null && _currentPollInterval == interval) return; + + _pollerSubscription?.cancel(); + _currentPollInterval = interval; + _pollerSubscription = Stream.periodic(interval, (_) {}) + .asyncMap((_) async { + if (mounted && !_fetching) { + await _fetchPicked(); + } + }) + .listen( + (_) {}, + onError: (error) { + debugPrint('[MYPICKUPS][STREAM ERROR] $error'); + }, + cancelOnError: false, + ); + } + + void _startPollingStream() => _syncPolling(); + + /// The screen stays awake only while a stop is actually live. + /// + /// It used to be held for the whole time Bookings was open — so a rider with + /// the phone in his pocket and nothing running had a screen that would not + /// sleep, which is the fastest way to flatten a battery mid-shift. Worse, it + /// was held *here*, on the list, and not on the map screen where he is + /// actually navigating and genuinely needs it. + void _syncWakelock() { + final bool keepAwake = _activePickups.isNotEmpty; + if (keepAwake == _wakelockHeld) return; + _wakelockHeld = keepAwake; + if (keepAwake) { + WakelockPlus.enable(); + } else { + WakelockPlus.disable(); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + final bool foreground = state == AppLifecycleState.resumed; + if (foreground == _pollForeground) return; + _pollForeground = foreground; + if (foreground) { + // One catch-up fetch immediately, so he never comes back to a queue that + // was true a minute ago, then the normal cadence. + _fetchPicked(force: true); + _syncPolling(); + } else { + _syncPolling(); + // Nothing is being watched, so nothing needs the screen. + if (_wakelockHeld) { + _wakelockHeld = false; + WakelockPlus.disable(); + } + } + } + + /// Duty state decides the idle poll rate. Read from the same pref the duty + /// toggle writes, so Bookings and Home agree without a second source. + Future _loadDutyState() async { + try { + final prefs = await SharedPreferences.getInstance(); + final int onduty = prefs.getInt('onduty') ?? 1; + if (!mounted) return; + final bool next = onduty != 0; + if (next != _onDuty) { + setState(() => _onDuty = next); + _syncPolling(); + } + } catch (_) { + // Duty is unreadable — assume on duty, which polls more often rather + // than less. Being wrong in the direction of freshness is the safe way + // to be wrong about whether a rider is working. + } + } + + 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('[MYPICKUPS] Error initializing location: $e'); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + BottomPage.currentTab.removeListener(_onTabChanged); + _pollerSubscription?.cancel(); + for (final timer in _pickupTimers.values) { + timer.cancel(); + } + _pickupTimers.clear(); + _pickupBasePayload.clear(); + // Persist current live pickup state for other screens + SharedPreferences.getInstance() + .then((p) => p.setBool('has_live_pickup', _picked.isNotEmpty)) + // ignore: body_might_complete_normally_catch_error + .catchError((_) {}); + if (_wakelockHeld) 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 bookings 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['pickuplat'] != newItem['pickuplat']) return false; + if (oldItem['pickuplong'] != newItem['pickuplong']) 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 Booking, String reason) { + if (!mounted) return; + final key = _getOrderKey(Booking); + // Record the skip locally so it sticks even if the backend hasn't recorded + // it yet (e.g. mock/offline data). _fetchPicked re-applies this cache on + // every poll so the status can't be reverted. + _skippedOrdersCache[key] = reason; + _skippedOrderTimestamps[key] = DateTime.now().millisecondsSinceEpoch; + + // Immediately reflect the skipped status so the card updates and the next + // stop unlocks without waiting on the (possibly failing) API round-trip. + setState(() { + for (final o in _picked) { + if (_getOrderKey(o) == key) { + o['orderstatus'] = 'skipped'; + } + } + }); + debugPrint('[MYPICKUPS] Marked skipped locally -> $key ("$reason")'); + _fetchPicked(force: true); + } + + // ✅ Check if there's an active pickup (blocks all actions) + bool _hasActivePickup() { + return _activePickups.isNotEmpty; + } + + /// 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 + /// blocks the next stop. + void markPickupFinished( + Map order, { + bool cancelled = false, + }) { + final key = _getOrderKey(order); + final orderId = (order['orderid'] ?? '').toString(); + _completedOrderKeys.add(key); + _stopPickupPosting(orderId); + // 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) + .then( + (_) => debugPrint( + '[ACTIVITY] recorded $orderId as ${cancelled ? 'cancelled' : 'picked'}', + ), + ) + // Fire-and-forget, but not silently: if this write ever fails the + // Activity tab is empty and there is nothing on screen to say why. + .catchError( + (Object e) => debugPrint('[ACTIVITY] failed to record $orderId: $e'), + ); + // Drop it from the local accepted store so a completed/cancelled stop stops + // counting as an open booking — otherwise it resurfaces on the Bookings tab + // (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]); + if (mounted) { + setState(() { + _activePickups.removeWhere((o) => _getOrderKey(o) == key); + _picked.removeWhere((o) => _getOrderKey(o) == key); + if (_activePickupOrderId == orderId) _activePickupOrderId = null; + // Straight onto the rail, ahead of the store write landing and the next + // poll reading it back. Newest first, matching the order + // [getCompletedBookings] returns — the rail reverses it. Without this + // the node the rider just closed stays grey for a poll cycle, which is + // the exact moment he looks at it. + if (!_completedToday.any((o) => _getOrderKey(o) == key)) { + final stamped = Map.from(order); + stamped['orderstatus'] = cancelled ? 'cancelled' : 'picked'; + _completedToday.insert(0, stamped); + } + }); + } + // Clear the persisted active-pickup markers so nothing stays "blocked" + // across a rebuild/restart. + SharedPreferences.getInstance().then((prefs) async { + if (_activePickups.isEmpty) { + await prefs.setBool('has_live_pickup', false); + await prefs.remove('active_pickup_order_id'); + } + }); + } + + /// Bookings still open for pickup (excludes finished/cancelled), used to seed + /// the "next stops" list on the completion screen. + List> remainingPickups() { + return _picked.where((o) { + return !stopStatusOf(o).isFinishedPickup && + !_completedOrderKeys.contains(_getOrderKey(o)); + }).toList(); + } + + // Previously showed a blocking dialog; now we just disable conflicting buttons in the UI. + void _showActivePickupBlockMessage() {} + + /// A rider must be online (on duty) to start/continue a pickup or delivery. + /// Returns true if online; otherwise shows a prompt and returns false. + Future ensureOnlineForAction() async { + // 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."); + } + return online; + } + + Future startPickup(Map item) async { + if (!mounted) return; + + // Online-gated: only an on-duty rider can start a pickup/delivery. + if (!await ensureOnlineForAction()) return; + if (!mounted) return; + + // ✅ BLOCK: Check if there's already an active pickup (and this isn't it) + final currentOrderId = (item['orderid'] ?? '').toString(); + final activeOrderIds = _activePickups + .map((d) => (d['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // Allow if this IS the active pickup, block if there's a different active pickup + if (_hasActivePickup() && !activeOrderIds.contains(currentOrderId)) { + // Just ignore taps on other cards when a different active pickup exists. + return; + } + + // Verify status is updated to ACTIVE before navigating + final dc = Get.put(PickupsController()); + final dId = int.tryParse((item['pickupid'] ?? 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( + pickupId: dId, + orderHeaderId: ohId, + orderId: (item['orderid'] ?? '').toString(), + ); + } + + await openScreen( + context, + _PickupMapScreen(pickup: item, parentState: this), + swipeToGoBack: false, + ); + } + + Future resumePickup(Map item) async { + // ✅ BLOCK: Check if there's already an active pickup (and this isn't it) + final currentOrderId = (item['orderid'] ?? '').toString(); + final activeOrderIds = _activePickups + .map((d) => (d['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // Allow if this IS the active pickup, block if there's a different active pickup + if (_hasActivePickup() && !activeOrderIds.contains(currentOrderId)) { + // Ignore resume taps when some other pickup 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 startPickup(item); + } + + Future _fetchPicked({bool force = false}) async { + if (_fetching) { + if (force) { + _fetching = false; + } else { + return; + } + } + + _fetching = true; + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userId') ?? prefs.getInt('userid') ?? 0; + + // ✅ CRITICAL: Fetch from v3 API (picked bookings) AND v2 API (all statuses) IN PARALLEL + + // Define V1 fetcher function (current/in-progress bookings) + Future> fetchV1() async { + try { + return await _provider.getCurrentPickups(); + } catch (e) { + debugPrint('[MYPICKUPS] Error fetching from v1 API: $e'); + } + return []; + } + + // Execute in parallel with individual error handling + Future> fetchV3() async { + try { + return await _provider.getPickupQueuesPicked(); + } catch (e) { + debugPrint('[MYPICKUPS] V3 API error: $e'); + // Return empty and let the single demo fallback below decide — two + // competing mock paths is how the old one ended up unreachable. + return []; + } + } + + final results = await Future.wait([fetchV3(), fetchV1()]); + + final itemsV3 = results[0] as List; + final itemsV1 = results[1] as List; + + // Merge v3 bookings with v1 bookings (current/in-progress) + final Map> mergedOrders = {}; + + // First add v3 bookings (picked/pickuped) + for (final Booking in itemsV3.whereType>()) { + final key = _getOrderKey(Booking); + mergedOrders[key] = Booking; + } + + // Then add/update with v1 bookings - V1 STATUS TAKES PRECEDENCE + for (final Booking in itemsV1.whereType>()) { + final key = _getOrderKey(Booking); + final existing = mergedOrders[key]; + + if (existing == null) { + mergedOrders[key] = Booking; + } else { + // If it exists in V3 (picked) but V1 says something else, trust V1. + // Especially for 'skipped', 'Picked up', 'cancelled'. + mergedOrders[key] = { + ...existing, + ...Booking, // Overwrite with V1 data + }; + } + } + + final items = mergedOrders.values.toList(); + debugPrint('[MYPICKUPS] Merged items count: ${items.length}'); + + // Today's finished stops, for the rail. Read on every fetch rather than + // held from the last completion, so the rail is right after a restart + // mid-shift too. + _completedToday = await getCompletedBookings(); + + // Merge in bookings accepted from Home (offline/mock accept flow) so the + // Bookings tab count grows as the rider accepts. + final acceptedBookings = await getAcceptedBookings(); + if (acceptedBookings.isNotEmpty) { + final existingKeys = items.map(_getOrderKey).toSet(); + int added = 0; + for (final Booking in acceptedBookings) { + if (existingKeys.add(_getOrderKey(Booking))) { + items.add(Booking); + added++; + } + } + debugPrint('[MYPICKUPS] Merged $added accepted bookings from Home'); + } + + // ── The day's shape, before any of this screen's filtering ── + // + // Everything today: what is assigned, what he took, what he finished. + // This is the only point in the fetch where all three are in one list, + // and it is what makes "Trip 2" mean the same thing here as it does on + // Home. See [_tripNumberByOrderId]. + _tripNumberByOrderId = _numberTrips([...items, ..._completedToday]); + + // Admin-assigned bookings the rider hasn't accepted yet belong on the + // Home tab, not here. Keep only bookings the rider has actually taken on: + // 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 ─────────────────────────────────────── + // + // Gated on the list that actually reaches the UI, not on the raw API + // response — which is what was still leaving this tab blank. + // + // 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. + // + // 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. + if (_completedOrderKeys.isNotEmpty) { + dedupedList.removeWhere( + (o) => _completedOrderKeys.contains(_getOrderKey(o)), + ); + } + + // Preserve step numbers for existing bookings + for (final Booking in dedupedList) { + final orderKey = _getOrderKey(Booking); + final currentStep = _getStepNumber(Booking); + if (currentStep > 0 && !_preservedStepNumbers.containsKey(orderKey)) { + _preservedStepNumbers[orderKey] = currentStep; + } + } + + // Sort the list + final sortedList = _sortOrders(dedupedList); + + // Definitive per-order status dump — tells us exactly why an order shows + // (or doesn't): e.g. "1234:picked" means it's completed, not assignable. + debugPrint( + '[MYPICKUPS] fetched statuses=' + '${sortedList.map((o) => "${o['orderid']}:${o['orderstatus']}").toList()}', + ); + + // Re-apply locally-skipped orders so a skip survives polling even when the + // backend hasn't recorded it yet (e.g. mock/offline data). Without this, + // the 3s poll would overwrite the skip with the raw API status. Applied + // before the filter below, because the filter now acts on it. + if (_skippedOrdersCache.isNotEmpty) { + for (final o in sortedList) { + if (_skippedOrdersCache.containsKey(_getOrderKey(o))) { + o['orderstatus'] = 'skipped'; + } + } + } + + // Build the top list = the stops he is actually working. + // - ACTIVE stops go to the banner, not the list. + // - FINISHED stops (picked / cancelled) must be excluded entirely, not + // merely hidden per-card. Otherwise the header/route-strip count + // (_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. + final listForTop = sortedList.where((o) { + final st = stopStatusOf(o); + return !st.isActive && !st.isFinishedPickup && !st.isSkipped; + }).toList(); + + debugPrint( + '[MYPICKUPS] Final top list: ${listForTop.length} ' + '(skipped held on Home: ${_skippedOrdersCache.length})', + ); + + // Check if data has actually changed before rebuilding + // This avoids unnecessary setState calls during polling + // Debug-only trace of every stage this list passes through. Three + // separate causes have made this tab look empty — a gate on the raw API + // count, a missing empty state on an unfilled trip tab, and stops + // filtered out as still-pending — and every one of them looked identical + // on screen. The counts are now shown on the empty state itself, so the + // failing stage is visible instead of guessed at. + _fetchDiag = + 'api=${items.length} ' + 'accepted=${acceptedIds.length} ' + 'workable=${dedupedList.length} ' + 'sorted=${sortedList.length} ' + 'list=${listForTop.length} ' + 'active=${sortedList.where((o) => stopStatusOf(o).isActive).length}'; + debugPrint('[MYPICKUPS][DIAG] $_fetchDiag'); + + if (_fetchFailed && mounted) setState(() => _fetchFailed = false); + + final bool hasChanged = !_areOrdersEqual(_picked, listForTop); + + if (hasChanged) { + if (mounted) { + setState(() { + _picked = listForTop; + }); + debugPrint( + '[MYPICKUPS] 🔄 UI Updated: ${listForTop.length} bookings', + ); + } + } else { + // No changes + } + + // Get all Booking IDs from the current list + final currentOrderIds = sortedList + .map((o) => (o['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // ✅ Find all ACTIVE pickup and ensure timers are running + final activeOrders = sortedList + .where((o) => stopStatusOf(o).isActive) + .toList(); + + debugPrint('[MYPICKUPS] Found ${activeOrders.length} active pickup'); + + // ✅ CRITICAL: Only set has_live_pickup to true if there are ACTIVE bookings (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_pickup', hasActiveOrders); + debugPrint( + '[MYPICKUPS] Set has_live_pickup: $hasActiveOrders (${activeOrders.length} active bookings)', + ); + + // Clear active_pickup_order_id if no active pickup + if (!hasActiveOrders) { + final activeOrderId = prefs.getString('active_pickup_order_id'); + if (activeOrderId != null && activeOrderId.isNotEmpty) { + debugPrint( + '[MYPICKUPS] Clearing stale active_pickup_order_id: $activeOrderId (no active pickup)', + ); + await prefs.remove('active_pickup_order_id'); + } + } + } catch (_) {} + + // Update active pickup list for banner display + if (mounted) { + setState(() { + _activePickups = activeOrders; + }); + // Whether a stop is live governs both how fast we poll and whether the + // screen is held awake, so they are re-derived wherever it changes. + _syncPolling(); + _syncWakelock(); + } + + // Get set of active Booking IDs + final activeOrderIds = activeOrders + .map((o) => (o['orderid'] ?? '').toString()) + .where((id) => id.isNotEmpty) + .toSet(); + + // ✅ CRITICAL: Start timers ONLY for active pickup that don't have one yet + // This ensures logs are posted every 30 seconds WITHOUT restarting timers on every fetch + for (final Booking in activeOrders) { + final orderId = (Booking['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 (!_pickupTimers.containsKey(orderId)) { + debugPrint( + '[MYPICKUPS] Active pickup: $orderId - Starting pickup log posting (timer not found)', + ); + // ✅ RE-ENABLED: Pickup logs now posted from pickup page with robust validation + await _startPickupPosting(Booking); + } else { + debugPrint( + '[MYPICKUPS] Active pickup: $orderId - Timer already running, skipping restart', + ); + } + + _activePickupOrderId = orderId; // Track the active pickup + + // Persist active pickup ID so it survives app restarts + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('active_pickup_order_id', orderId); + } catch (e) { + debugPrint('[MYPICKUPS] Error saving active pickup ID: $e'); + } + } + + // ✅ Stop timers ONLY for pickup that are no longer active or in the list + final timersToStop = _pickupTimers.keys + .where( + (id) => + !activeOrderIds.contains(id) || !currentOrderIds.contains(id), + ) + .toList(); + + for (final id in timersToStop) { + debugPrint( + '[MYPICKUPS] Stopping timer for orderId: $id (no longer active)', + ); + _stopPickupPosting(id); + } + + debugPrint('[MYPICKUPS] Active timers: ${_pickupTimers.keys.toList()}'); + debugPrint('[MYPICKUPS] Active pickup orderId: $_activePickupOrderId'); + } catch (e) { + debugPrint('[MYPICKUPS] Error fetching picked bookings: $e'); + if (mounted) setState(() => _fetchFailed = true); + } finally { + _fetching = false; + if (!_firstLoadDone && mounted) { + setState(() => _firstLoadDone = true); + } + } + } + + // 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, + 'pickupid': it['pickupid'] ?? 0, + 'userid': it['userid'] ?? 0, + 'orderid': (it['orderid'] ?? '').toString(), + 'orderstatus': 'active', + }; + } + + // Stop posting logs for a specific pickup + void _stopPickupPosting(String orderId) { + final timer = _pickupTimers.remove(orderId); + if (timer != null) { + timer.cancel(); + debugPrint('[PICKUPLOG] Stopped posting logs for orderId: $orderId'); + } else { + debugPrint('[PICKUPLOG] 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( + '[PICKUPS][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( + '[PICKUPS][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( + '[PICKUPS][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( + '[PICKUPS][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( + '[PICKUPS][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('[PICKUPS][COORDS] ✅ Got valid coordinates: $lat, $lng'); + return (lat, lng); + } else { + debugPrint( + '[PICKUPS][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( + '[PICKUPS][COORDS] ✅ Using cached coordinates: $lat, $lng', + ); + return (lat, lng); + } + } + } catch (_) {} + + // Retry if we haven't exceeded max retries + if (retryCount < maxRetries) { + debugPrint( + '[PICKUPS][COORDS] ⚠️ Retry ${retryCount + 1}/$maxRetries to get coordinates', + ); + await Future.delayed(const Duration(milliseconds: 500)); + return _getValidCoordinates(retryCount: retryCount + 1); + } + + debugPrint( + '[PICKUPS][COORDS] ❌ Failed to get valid coordinates after $maxRetries retries', + ); + return null; + } catch (e) { + debugPrint('[PICKUPS][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 _startPickupPosting(Map Booking) async { + final orderId = (Booking['orderid'] ?? '').toString(); + if (orderId.isEmpty) { + debugPrint('[PICKUPS][PICKUPLOG] ⚠️ Cannot start timer: empty orderId'); + return; + } + + // Safety check: If timer already exists, cancel it first (shouldn't happen after cleanup above) + if (_pickupTimers.containsKey(orderId)) { + debugPrint( + '[PICKUPS][PICKUPLOG] ⚠️ Timer already exists for $orderId, canceling old one', + ); + _pickupTimers[orderId]?.cancel(); + _pickupTimers.remove(orderId); + } + + debugPrint( + '[PICKUPS][PICKUPLOG] 🚀 Starting 30-second timer for orderId: $orderId', + ); + + debugPrint( + '[PICKUPS][PICKUPLOG] 🚀 Starting 30-second timer for orderId: $orderId', + ); + + // REMOVED: Do not reset cumulative distance here. + // It is already reset in PickupsController.updateActiveStatus when the status actually changes. + // Resetting here causes data loss if the app is restarted while a pickup 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( + '[PICKUPS][PICKUPLOG] 🛡️ Activating Foreground Service via RiderLogController...', + ); + // Force 30s interval to match pickup logging + riderLog.startAutoCreateLoginLoop(seconds: 30); + } + } catch (e) { + debugPrint( + '[PICKUPS][PICKUPLOG] ⚠️ Could not start Foreground Service: $e', + ); + } + + final pickupId = (Booking['pickupid'] ?? 0).toString(); + + // Get starttime from SharedPreferences (saved when Booking became active via updateActiveStatus) + // If not found, use activetime from Booking data, or current time as fallback + String startTime = ''; + try { + final prefs = await SharedPreferences.getInstance(); + + // Method 1: Get from SharedPreferences (saved when Booking became active) + startTime = prefs.getString('pickup_starttime_$pickupId') ?? ''; + if (startTime.isNotEmpty) { + debugPrint( + '[PICKUPS][PICKUPLOG] ✅ Loaded starttime from SharedPreferences: $startTime', + ); + } + + // Method 2: Fallback - try to get from Booking data (starttime field) + if (startTime.isEmpty) { + startTime = (Booking['starttime'] ?? Booking['startTime'] ?? '') + .toString(); + if (startTime.isNotEmpty) { + debugPrint( + '[PICKUPS][PICKUPLOG] ✅ Loaded starttime from Booking data: $startTime', + ); + } + } + + // Method 3: Fallback - try activetime from Booking data + if (startTime.isEmpty) { + final activetime = (Booking['activetime'] ?? Booking['activTime'] ?? '') + .toString(); + if (activetime.isNotEmpty) { + startTime = activetime; + debugPrint( + '[PICKUPS][PICKUPLOG] ✅ 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( + '[PICKUPS][PICKUPLOG] ⚠️ Using current time as starttime fallback: $startTime', + ); + } + } catch (e) { + debugPrint('[PICKUPS][PICKUPLOG] ❌ 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( + '[PICKUPS][PICKUPLOG] ⚠️ Final fallback: starttime was empty, using: $startTime', + ); + } + + debugPrint( + '[PICKUPS][PICKUPLOG] 📝 Final starttime for orderId $orderId: $startTime', + ); + + // Create base payload with starttime + final base = { + 'logid': 0, + 'tenantid': Booking['tenantid'] ?? 0, + 'partnerid': Booking['partnerid'] ?? 0, + 'locationid': Booking['locationid'] ?? 0, + 'orderheaderid': Booking['orderheaderid'] ?? 0, + 'pickupid': Booking['pickupid'] ?? 0, + 'userid': Booking['userid'] ?? 0, + 'orderid': orderId, + 'orderstatus': 'active', + 'starttime': startTime, // Include starttime in base payload + }; + + _pickupBasePayload[orderId] = base; + + // Save to SharedPreferences for persistence + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'pickuplog_${orderId}_tenantid', + (base['tenantid'] ?? 0).toString(), + ); + await prefs.setString( + 'pickuplog_${orderId}_partnerid', + (base['partnerid'] ?? 0).toString(), + ); + await prefs.setString( + 'pickuplog_${orderId}_locationid', + (base['locationid'] ?? 0).toString(), + ); + await prefs.setString( + 'pickuplog_${orderId}_orderheaderid', + (base['orderheaderid'] ?? 0).toString(), + ); + await prefs.setString( + 'pickuplog_${orderId}_pickupid', + (base['pickupid'] ?? 0).toString(), + ); + await prefs.setString( + 'pickuplog_${orderId}_userid', + (base['userid'] ?? 0).toString(), + ); + await prefs.setString('pickuplog_${orderId}_orderid', orderId); + await prefs.setString('pickuplog_${orderId}_orderstatus', 'active'); + await prefs.setString( + 'pickuplog_${orderId}_starttime', + startTime, + ); // Save starttime + } catch (e) { + debugPrint('[PICKUPS][PICKUPLOG] Error saving payload: $e'); + } + + // Post once immediately (don't await - let it run in background) + _postPickupLog(orderId, Booking); + debugPrint( + '[PICKUPS][PICKUPLOG] 📤 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( + '[PICKUPS][PICKUPLOG] ⏰ Timer tick for orderId: $orderId (30 seconds elapsed)', + ); + _postPickupLog(orderId, Booking); + }); + + _pickupTimers[orderId] = timer; + debugPrint( + '[PICKUPS][PICKUPLOG] ✅ Timer registered for orderId: $orderId (will post every 30 seconds)', + ); + } + + // Reset cumulative distance when Booking becomes active + Future _resetCumulativeDistance(String pickupId) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('pickup_tracking_${pickupId}_lastLat'); + await prefs.remove('pickup_tracking_${pickupId}_lastLng'); + await prefs.remove('pickup_tracking_${pickupId}_cumulativeKm'); + debugPrint( + '[PICKUPS] 🧹 Reset cumulative distance tracking for pickupId: $pickupId', + ); + } catch (e) { + debugPrint('[PICKUPS] Error resetting cumulative distance: $e'); + } + } + + void _postPickupLog(String orderId, Map Booking) { + if (!mounted) { + debugPrint('[PICKUPS][PICKUPLOG][POST] Widget disposed, skipping'); + return; + } + + debugPrint( + '[PICKUPS][PICKUPLOG][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( + '[PICKUPS][PICKUPLOG][POST] 🔄 Starting _performPost for orderId: $orderId', + ); + + Map? base = _pickupBasePayload[orderId]; + + if (base == null) { + debugPrint( + '[PICKUPS][PICKUPLOG][POST] Base is null, loading from SharedPreferences', + ); + try { + final prefs = await SharedPreferences.getInstance(); + if (!mounted) { + debugPrint( + '[PICKUPS][PICKUPLOG][POST] Widget unmounted after prefs load', + ); + return; + } + + base = { + 'logid': 0, + 'tenantid': + int.tryParse( + prefs.getString('pickuplog_${orderId}_tenantid') ?? '0', + ) ?? + 0, + 'partnerid': + int.tryParse( + prefs.getString('pickuplog_${orderId}_partnerid') ?? '0', + ) ?? + 0, + 'locationid': + int.tryParse( + prefs.getString('pickuplog_${orderId}_locationid') ?? '0', + ) ?? + 0, + 'orderheaderid': + int.tryParse( + prefs.getString('pickuplog_${orderId}_orderheaderid') ?? '0', + ) ?? + 0, + 'pickupid': + int.tryParse( + prefs.getString('pickuplog_${orderId}_pickupid') ?? '0', + ) ?? + 0, + 'userid': + int.tryParse( + prefs.getString('pickuplog_${orderId}_userid') ?? '0', + ) ?? + 0, + 'orderid': + prefs.getString('pickuplog_${orderId}_orderid') ?? orderId, + 'orderstatus': + prefs.getString('pickuplog_${orderId}_orderstatus') ?? 'active', + 'starttime': + prefs.getString('pickuplog_${orderId}_starttime') ?? + '', // Load starttime + }; + debugPrint( + '[PICKUPS][PICKUPLOG][POST] Base loaded from prefs: $base', + ); + } catch (e) { + debugPrint('[PICKUPS][PICKUPLOG][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('[PICKUPS][PICKUPLOG][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( + '[PICKUPS][PICKUPLOG][POST] ❌ Coordinate timeout after retries', + ); + return null; + }, + ); + + if (!mounted) { + debugPrint('[PICKUPS][PICKUPLOG][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( + '[PICKUPS][PICKUPLOG][POST] ❌ SKIPPING POST: Invalid coordinates (lat=${coords?.$1 ?? 'null'}, lng=${coords?.$2 ?? 'null'})', + ); + debugPrint( + '[PICKUPS][PICKUPLOG][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( + '[PICKUPS][PICKUPLOG][POST] ❌ SKIPPING POST: Invalid coordinate ranges (lat=$latDouble, lng=$lngDouble)', + ); + debugPrint( + '[PICKUPS][PICKUPLOG][POST] ⚠️ Will retry on next timer tick (30 seconds)', + ); + return; // Skip this post - don't send invalid coordinates + } + + debugPrint( + '[PICKUPS][PICKUPLOG][POST] ✅ Valid coordinates: lat=${coords.$1}, lng=${coords.$2}', + ); + + // CRITICAL: Track cumulative distance traveled from active to Picked up/cancelled + // Calculate distance from last GPS location and accumulate + final pickupId = base['pickupid']?.toString() ?? ''; + if (pickupId.isNotEmpty) { + try { + final prefs = await SharedPreferences.getInstance(); + + // Get last GPS location for this pickup + final lastLatStr = + prefs.getString('pickup_tracking_${pickupId}_lastLat') ?? ''; + final lastLngStr = + prefs.getString('pickup_tracking_${pickupId}_lastLng') ?? ''; + final currentCumulativeKm = + double.tryParse( + prefs.getString('pickup_tracking_${pickupId}_cumulativeKm') ?? + '0', + ) ?? + 0.0; + + final currentLat = double.tryParse(coords.$1) ?? 0.0; + final currentLng = double.tryParse(coords.$2) ?? 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 && + currentLat != 0 && + currentLng != 0) { + // Calculate distance from last location to current location + final distanceMeters = Geolocator.distanceBetween( + lastLat, + lastLng, + currentLat, + currentLng, + ); + + // Only add if distance is significant (filter out GPS noise/jitter) + // Ignore movements less than 10 meters (likely GPS drift) + if (distanceMeters >= 10.0) { + final distanceKm = distanceMeters / 1000.0; + final newCumulativeKm = currentCumulativeKm + distanceKm; + + // Save updated cumulative distance + await prefs.setString( + 'pickup_tracking_${pickupId}_cumulativeKm', + newCumulativeKm.toStringAsFixed(4), + ); + debugPrint( + '[PICKUPS][PICKUPLOG][TRACKING] 📍 Distance segment: ${distanceKm.toStringAsFixed(4)} km (${distanceMeters.toStringAsFixed(0)}m)', + ); + debugPrint( + '[PICKUPS][PICKUPLOG][TRACKING] 📊 Cumulative distance: ${newCumulativeKm.toStringAsFixed(4)} km', + ); + } else { + debugPrint( + '[PICKUPS][PICKUPLOG][TRACKING] ⏭️ Skipped small movement: ${distanceMeters.toStringAsFixed(1)}m (GPS noise)', + ); + } + } + } + + // Save current location as last location for next calculation + await prefs.setString( + 'pickup_tracking_${pickupId}_lastLat', + coords.$1, + ); + await prefs.setString( + 'pickup_tracking_${pickupId}_lastLng', + coords.$2, + ); + } catch (e) { + debugPrint( + '[PICKUPS][PICKUPLOG][TRACKING] ❌ Error tracking cumulative distance: $e', + ); + } + } + + 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( + '[PICKUPS][PICKUPLOG][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', + 'pickupid', + 'userid', + 'orderid', + 'orderstatus', + 'starttime', + ]; + final missingFields = requiredFields + .where((field) => payload[field] == null || payload[field] == '') + .toList(); + if (missingFields.isNotEmpty) { + debugPrint( + '[PICKUPS][PICKUPLOG][POST] ⚠️ WARNING: Missing fields in payload: $missingFields', + ); + } + + + debugPrint('[PICKUPS][PICKUPLOG][POST] 📦 Payload: $payload'); + debugPrint( + '[PICKUPS][PICKUPLOG][POST] ✅ starttime in payload: "${payload['starttime']}"', + ); + + await _pickupLogProvider + .createPickupLog(payload) + .timeout( + const Duration(seconds: 8), + onTimeout: () { + debugPrint( + '[PICKUPS][PICKUPLOG][POST] ⚠️ API timeout for orderId: $orderId', + ); + throw TimeoutException('API timeout', const Duration(seconds: 8)); + }, + ); + + debugPrint( + '[PICKUPS][PICKUPLOG][POST] ✅ SUCCESS for orderId: $orderId at ${DateTime.now()}', + ); + } catch (e, stackTrace) { + debugPrint( + '[PICKUPS][PICKUPLOG][POST] ❌ ERROR for orderId: $orderId - $e', + ); + debugPrint('[PICKUPS][PICKUPLOG][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 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.', + ), + 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: Scaffold( + // The same white as Home, Activity and Account. This was `surface` + // (#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, + appBar: MilerAppBar( + title: 'My Bookings', + 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, + ), + ), + 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, + ), + ); + } + + // 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) + 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(); + } + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/pickups/pip.dart b/lib/views/Dashboard/pickups/pip.dart new file mode 100644 index 0000000..611b63a --- /dev/null +++ b/lib/views/Dashboard/pickups/pip.dart @@ -0,0 +1,173 @@ +part of 'pickups.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 pickup details, but still relatively compact + body: Center( + child: widget.orderId.isEmpty || widget.orderId == 'N/A' + ? Text( + 'No Active Booking', + style: TextStyle( + fontSize: 12, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.primary, + ), + ) + : Card( + color: Colors.white, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + 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.primary, + width: 3, + ), + color: Colors.white, + ), + alignment: Alignment.center, + child: Text( + 'Out', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: ColorConstants.primary, + fontFamily: FontConstants.fontFamily, + ), + ), + ) + : CircularCountDownTimer( + duration: _durationSeconds, + initialDuration: 0, + controller: _controller, + width: 72, + height: 72, + ringColor: ColorConstants.primary.withValues( + alpha: 0.15, + ), + fillColor: ColorConstants.primary, + backgroundColor: Colors.white, + strokeWidth: 5, + strokeCap: StrokeCap.round, + textStyle: TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + color: ColorConstants.primary, + 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 Booking id + Text( + 'Active Booking ID: ${widget.orderId}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/pickups/route_metrics.dart b/lib/views/Dashboard/pickups/route_metrics.dart new file mode 100644 index 0000000..d6b1961 --- /dev/null +++ b/lib/views/Dashboard/pickups/route_metrics.dart @@ -0,0 +1,189 @@ +import 'dart:math' as math; + +/// Live per-stop route metrics — how far away the next stop is, and when the +/// rider will get there. +/// +/// Why this is its own file: distance was previously formatted in three +/// different places with three different rules, and always in kilometres. A +/// stop 80 metres away rendered as `0.1 km`, which is useless when you are +/// looking for a gate. Riders think in **metres up close and kilometres far +/// away**, so the unit has to switch at the threshold they actually use. +/// +/// Everything here is pure — no Flutter, no I/O — so it is unit-testable and +/// safe to call from `build`. +class RouteMetricsHelper { + RouteMetricsHelper._(); + + /// Below this many metres, show metres. At or above it, show kilometres. + static const double kMetreThreshold = 1000; + + /// Fallback speed when the rider's live speed is unknown or unusable: + /// 20 km/h, a realistic urban two-wheeler average once lights, turns and + /// traffic are counted. Riding speed alone would promise ETAs he can't hit. + static const double kDefaultSpeedMps = 5.55; // 20 km/h + + /// Speeds below this are noise (stopped at a light, GPS jitter) and must not + /// drive an ETA — at 0.5 m/s a 2 km leg would read as over an hour. + static const double kMinUsableSpeedMps = 1.5; // ~5.4 km/h + + /// Clamp on live speed. A GPS spike of 200 km/h on a bike is a bad fix, not + /// a fast rider, and would promise an ETA that has already passed. + static const double kMaxUsableSpeedMps = 22.0; // ~79 km/h + + // ── Distance ─────────────────────────────────────────────────────────── + + /// Great-circle distance between two coordinates, in metres. + static double distanceMeters( + double fromLat, + double fromLng, + double toLat, + double toLng, + ) { + const earthRadiusM = 6371000.0; + final dLat = _rad(toLat - fromLat); + final dLng = _rad(toLng - fromLng); + final h = + math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_rad(fromLat)) * + math.cos(_rad(toLat)) * + math.sin(dLng / 2) * + math.sin(dLng / 2); + return earthRadiusM * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h)); + } + + /// Formats a distance the way a rider reads it. + /// + /// • `< 1000 m` → whole metres, rounded to 10 (`450 m`, `80 m`) + /// • `>= 1000 m` → kilometres with one decimal (`3.2 km`, `12.0 km`) + /// • `>= 100 km` → whole kilometres (`104 km`) — the decimal is noise + /// + /// Returns `'—'` for unknown/zero, never a misleading `0 m`. + static String formatDistance(double? meters) { + if (meters == null || meters.isNaN || meters <= 0) return '—'; + + if (meters < kMetreThreshold) { + // Round to the nearest 10 m — GPS is not accurate to the metre, and + // "450 m" is easier to hold in your head than "447 m". + final rounded = (meters / 10).round() * 10; + return '${rounded < 10 ? 10 : rounded} m'; + } + + final km = meters / 1000; + if (km >= 100) return '${km.round()} km'; + return '${km.toStringAsFixed(1)} km'; + } + + /// Distance with the trailing word riders expect on a card: `450 m away`. + static String formatDistanceAway(double? meters) { + final d = formatDistance(meters); + return d == '—' ? 'Distance unknown' : '$d away'; + } + + // ── Time ─────────────────────────────────────────────────────────────── + + /// Usable travel speed in m/s, given the rider's live speed. + /// + /// Falls back to [kDefaultSpeedMps] whenever the live reading is missing, + /// too slow to be movement, or too fast to be real. + static double usableSpeedMps(double? liveSpeedMps) { + if (liveSpeedMps == null || liveSpeedMps.isNaN) return kDefaultSpeedMps; + if (liveSpeedMps < kMinUsableSpeedMps) return kDefaultSpeedMps; + if (liveSpeedMps > kMaxUsableSpeedMps) return kDefaultSpeedMps; + return liveSpeedMps; + } + + /// Travel time for [meters] at the rider's usable speed. + static Duration travelTime(double? meters, {double? liveSpeedMps}) { + if (meters == null || meters.isNaN || meters <= 0) return Duration.zero; + final seconds = meters / usableSpeedMps(liveSpeedMps); + return Duration(seconds: seconds.round()); + } + + /// Arrival clock time for [meters] away, from [now] (defaults to real now). + static DateTime? eta(double? meters, {double? liveSpeedMps, DateTime? now}) { + if (meters == null || meters.isNaN || meters <= 0) return null; + return (now ?? DateTime.now()).add( + travelTime(meters, liveSpeedMps: liveSpeedMps), + ); + } + + /// ETA as a clock time — `4:35 PM`. Returns `'—'` when it can't be computed. + static String formatEta( + double? meters, { + double? liveSpeedMps, + DateTime? now, + }) { + final arrival = eta(meters, liveSpeedMps: liveSpeedMps, now: now); + if (arrival == null) return '—'; + return formatClock(arrival); + } + + /// `14:05` → `2:05 PM`. + static String formatClock(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'}'; + } + + /// Duration as a rider reads a clock: `4 min`, `1h 05m`. + static String formatDuration(Duration d) { + if (d.inSeconds <= 0) return '—'; + if (d.inMinutes < 1) return '1 min'; + if (d.inMinutes < 60) return '${d.inMinutes} min'; + final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + return '${d.inHours}h ${minutes}m'; + } + + /// The one-line strip shown on every stop card: + /// `450 m away · ETA 4:35 PM`. Degrades to whichever half is known. + static String formatStrip( + double? meters, { + double? liveSpeedMps, + DateTime? now, + }) { + final distance = formatDistanceAway(meters); + final arrival = formatEta(meters, liveSpeedMps: liveSpeedMps, now: now); + if (arrival == '—') return distance; + return '$distance · ETA $arrival'; + } + + // ── Stop-map convenience ─────────────────────────────────────────────── + + /// Resolves the distance from the rider to a stop, in metres. + /// + /// Prefers a live rider fix; falls back to the coordinates the backend + /// stamped on the booking, then to its precomputed `kms` field. Returns null + /// when nothing usable exists — callers render `—` rather than a fake zero. + static double? metersToStop( + Map stop, { + double? riderLat, + double? riderLng, + }) { + final stopLat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']); + final stopLng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']); + + final fromLat = (riderLat != null && riderLat != 0) + ? riderLat + : _toDouble(stop['riderslat']); + final fromLng = (riderLng != null && riderLng != 0) + ? riderLng + : _toDouble(stop['riderslon']); + + if (stopLat != 0 && stopLng != 0 && fromLat != 0 && fromLng != 0) { + return distanceMeters(fromLat, fromLng, stopLat, stopLng); + } + + // Backend-precomputed kilometres, if it sent any. + final km = _toDouble(stop['kms'] ?? stop['km']); + return km > 0 ? km * 1000 : null; + } + + static double _toDouble(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toDouble(); + final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), ''); + return double.tryParse(cleaned) ?? 0; + } + + static double _rad(double deg) => deg * math.pi / 180.0; +} diff --git a/lib/views/Dashboard/pickups/sheet.dart b/lib/views/Dashboard/pickups/sheet.dart new file mode 100644 index 0000000..f38ca20 --- /dev/null +++ b/lib/views/Dashboard/pickups/sheet.dart @@ -0,0 +1,1261 @@ +// ignore_for_file: invalid_return_type_for_catch_error + +part of 'pickups.dart'; + +// ───────────────────────────────────────────────────────────────────────────── +// UPDATE STATUS BOTTOM SHEET — PREMIUM REDESIGN +// Preserves all Slidable swipe + SliderButton logic exactly +// ───────────────────────────────────────────────────────────────────────────── +class _PickupBottomSheet extends StatefulWidget { + final Map pickup; + final _MyPickupsState? parentState; + + const _PickupBottomSheet({super.key, required this.pickup, this.parentState}); + + @override + State<_PickupBottomSheet> createState() => _PickupBottomSheetState(); +} + +class _PickupBottomSheetState extends State<_PickupBottomSheet> + with SingleTickerProviderStateMixin { + String selectedOption = ''; + bool isPickupsSelected = true; + bool showOptions = false; + bool showSlider = true; + bool isLoading = false; + bool isProcessing = false; + DateTime? lastFailureTime; + + late AnimationController _optionsAnimController; + late Animation _optionsFade; + + final List cancelOptions = [ + 'Customer refused', + 'Not Reachable', + 'Switched Off', + 'Incorrect Location', + ]; + + /// What the payment page recorded, when this stop had money on it. + /// + /// The sheet used to *ask* for the method itself, with its own chip list, and + /// then hand the answer to a payment page that asked again. These hold the one + /// answer, given once, on the screen that owns the question. + String? _paidMethod; + double _paidAmount = 0; + + @override + void initState() { + super.initState(); + _resetSheetState(); + _optionsAnimController = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _optionsFade = CurvedAnimation( + parent: _optionsAnimController, + curve: Curves.easeOut, + ); + } + + @override + void dispose() { + _optionsAnimController.dispose(); + super.dispose(); + } + + void _resetSheetState() { + selectedOption = ''; + showOptions = false; + showSlider = true; + isPickupsSelected = true; + isProcessing = false; + 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; + } + + 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 _getPickupAmount() { + final raw = widget.pickup['pickupamt']; + if (raw == null) return 0.0; + if (raw is num) return raw.toDouble(); + return double.tryParse(raw.toString()) ?? 0.0; + } + + /// Outlined secondary action button used in the action row (Skip / Cancel). + Widget _sheetSecondaryButton({ + required String label, + required IconData icon, + required Color color, + required VoidCallback onTap, + }) { + return SizedBox( + height: ButtonSizes.secondary, + child: OutlinedButton.icon( + onPressed: onTap, + icon: Icon(icon, color: color, size: 18.sp), + label: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w700, + color: color, + fontFamily: FontConstants.fontFamily, + ), + ), + style: OutlinedButton.styleFrom( + side: BorderSide(color: color.withValues(alpha: 0.4)), + 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), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ); + } + + @override + 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; + + // Only the cancel path asks a question here now. Money is asked about once, + // on the payment page — see [_onPickedUp]. + final options = cancelOptions; + + final Color confirmColor = isPickupsSelected + ? ColorConstants.acceptGreen + : ColorConstants.errorRed; + + final String modeLabel = isPickupsSelected + ? (isDelivery ? 'Delivered' : 'Picked up') + : (isDelivery ? 'Undelivered' : 'Cancelled'); + final IconData modeIcon = isPickupsSelected + ? Icons.check_circle_rounded + : Icons.cancel_rounded; + + // 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 + // amount and any short-payment reason. + final bool showReasonList = showOptions && !isPickupsSelected; + + // Same frosted material as the map sheet and the app bars — see + // [milerGlassSheet]. These two sheets are the ones a rider is in most, and + // being made of different stuff to everything around them is most of why + // they read as belonging to another app. + return milerGlassSheet( + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 16.h), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + 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), + + Row( + children: [ + // Mode badge (only when options shown) + if (showOptions) ...[ + Container( + padding: EdgeInsets.symmetric( + horizontal: 10.w, + vertical: 6.h, + ), + decoration: BoxDecoration( + color: confirmColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(modeIcon, color: confirmColor, size: 14.sp), + SizedBox(width: 5.w), + Text( + modeLabel, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w700, + color: confirmColor, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + SizedBox(width: 10.w), + ], + Expanded( + child: Text( + showOptions + ? (isPickupsSelected + ? (isDelivery + ? 'Confirm delivery' + : 'Collect payment') + : 'Select reason') + : 'Update Status', + style: TextStyle( + fontSize: 19.sp, + fontWeight: FontWeight.w800, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // Close / Back button + GestureDetector( + onTap: () { + if (!showSlider && showOptions) { + setState(() { + _resetSheetState(); + _optionsAnimController.reverse(); + }); + } else { + Navigator.pop(context); + } + }, + child: Container( + padding: EdgeInsets.all(8.r), + decoration: BoxDecoration( + color: ColorConstants.neutralLight, + shape: BoxShape.circle, + ), + child: Icon( + showOptions + ? Icons.arrow_back_rounded + : Icons.close_rounded, + color: ColorConstants.onSurface, + size: 18.sp, + ), + ), + ), + ], + ), + + SizedBox(height: 16.h), + + // ── Customer Info (shown before swipe) ────────────────── + if (!showOptions && showSlider) ...[ + _buildCustomerInfo(), + SizedBox(height: 16.h), + ], + + // ── ACTION BUTTONS (tap, not swipe) ───────────────────── + // Three clear choices, one tap each. Primary = Picked up / + // Delivered; then Skip and Cancel / Undelivered. + if (showSlider) ...[ + // Primary: Picked up / Delivered + SizedBox( + width: double.infinity, + height: ButtonSizes.primary, + child: ElevatedButton.icon( + onPressed: () => _onPickedUp(isDelivery), + icon: const Icon( + Icons.check_circle_rounded, + color: Colors.white, + ), + label: Text( + isDelivery ? 'Delivered' : 'Picked up', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w800, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.acceptGreen, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + ), + ), + ), + SizedBox(height: 10.h), + // Secondary row: Skip · Cancel/Undelivered + Row( + children: [ + Expanded( + child: _sheetSecondaryButton( + label: 'Skip', + icon: Icons.skip_next_rounded, + color: ColorConstants.warning, + onTap: () async { + final resolvedParentState = + widget.parentState ?? + context + .findAncestorStateOfType<_MyPickupsState>(); + await _showMyOptionsSheet( + context, + widget.pickup, + resolvedParentState, + ); + if (mounted) Navigator.pop(context, true); + }, + ), + ), + SizedBox(width: 10.w), + Expanded( + child: _sheetSecondaryButton( + label: isDelivery ? 'Undelivered' : 'Cancel', + icon: Icons.cancel_rounded, + color: ColorConstants.errorRed, + onTap: () { + setState(() { + isPickupsSelected = false; + selectedOption = ''; + showOptions = true; + showSlider = false; + isProcessing = false; + lastFailureTime = null; + }); + _optionsAnimController.forward(from: 0); + }, + ), + ), + ], + ), + ], + + // ── Reason Options List ───────────────────────────────── + // + // ── Rows, not four outlined cards ── + // + // Every reason was a white box with a 1px border, on a white + // sheet — four rectangles whose only job was to say "these are + // separate", which the gap between them already said. Each one + // also carried its icon inside a *second* box. Eight enclosures + // to ask one question with four answers. + // + // A hairline between rows does the separating, the tick does + // the answering, and selection is carried by a fill rather than + // by a thicker outline — the same way this app marks a chosen + // condition on the verification page, and the same way Uber + // marks a chosen reason. The fill is what survives sunlight; + // a 1.5px border is what does not. + if (showReasonList) + FadeTransition( + opacity: _optionsFade, + child: Column( + children: [ + for (var i = 0; i < options.length; i++) + _ReasonRow( + label: options[i], + icon: _cancelIcon(options[i]), + accent: confirmColor, + selected: options[i] == selectedOption, + showRule: + i > 0 && + options[i] != selectedOption && + options[i - 1] != selectedOption, + onTap: (isLoading || isProcessing) + ? null + : () => setState(() { + selectedOption = options[i]; + lastFailureTime = null; + }), + ), + ], + ), + ), + + SizedBox(height: 16.h), + + // ── Confirm (tap). For pickups with a payment method the + // cash/QR dialog is the deliberate money confirmation; cancels + // require a reason to be selected first. ───────────────── + if (selectedOption.isNotEmpty) + SizedBox( + width: double.infinity, + height: ButtonSizes.primary, + child: ElevatedButton( + onPressed: (isLoading || isProcessing) + ? null + : () => _handleConfirm(), + style: ElevatedButton.styleFrom( + backgroundColor: confirmColor, + disabledBackgroundColor: confirmColor.withValues( + alpha: 0.5, + ), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + ), + child: isLoading + ? const SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + isPickupsSelected + ? Icons.check_rounded + : Icons.close_rounded, + size: 22.sp, + color: Colors.white, + ), + SizedBox(width: 10.w), + // Flexible: "Confirm delivery" beside a 22sp + // icon is wider than the button from ~1.3x + // system text, and an overflow stripe across + // the primary action is the last thing the + // rider should be looking at. + Flexible( + child: Text( + isPickupsSelected + ? (isDelivery + ? 'Confirm delivery' + : 'Confirm pickup') + : (isDelivery + ? 'Mark undelivered' + : 'Cancel booking'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w800, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + /// The stop this sheet is closing. See [_StopBrief] — the same block the map + /// sheet and the skip sheet show, so the rider sees one identity for one stop + /// however he arrived at it. + Widget _buildCustomerInfo() => + _StopBrief(pickup: widget.pickup, showRule: true); + + // ── Icon helpers ────────────────────────────────────────────────────────── + /// "Picked up" / "Delivered", and what has to happen next. + /// + /// ── One question about money, asked once ── + /// + /// This sheet used to ask "how did the customer pay?" with its own list of + /// chips — Cash (₹450) / UPI (₹450) / Not Collected — and then, after the + /// rider confirmed, open a payment page that asked exactly the same question + /// again with its own tiles. Four taps and two answers for one fact, and the + /// two screens could disagree: the chip said UPI, the page defaulted to cash, + /// and whichever the rider left last is what the office was told. + /// + /// The payment page owns the question now, because it is the screen that also + /// owns the amount, the QR, the short-payment path and the "customer cannot + /// pay" escape. This sheet just gets him there and takes the answer back. + /// + /// money owed → Picked up → [payment page] → slide (2 actions) + /// nothing owed → Picked up → Confirm pickup (2 actions) + /// delivery → Delivered → Confirm delivery (2 actions) + /// + /// It was four plus a slide. + Future _onPickedUp(bool isDelivery) async { + if (isLoading || isProcessing) return; + + // Prepaid, or nothing to collect: there is no money question to ask, so go + // straight to the deliberate confirm. + if (isDelivery || !_hasCollection()) { + setState(() { + isPickupsSelected = true; + selectedOption = isDelivery ? 'Delivered' : 'No collection'; + showOptions = true; + showSlider = false; + isProcessing = false; + lastFailureTime = null; + }); + _optionsAnimController.forward(from: 0); + return; + } + + final result = await openSheet>( + context, + CollectPaymentPage(pickup: widget.pickup), + ); + + // Dismissed, or the customer could not pay: the stop stays open and the + // sheet stays exactly where it was, on its three buttons. + if (!mounted || result == null || result['paid'] != true) return; + + setState(() { + isPickupsSelected = true; + _paidMethod = (result['method'] ?? 'cash').toString(); + _paidAmount = (result['amountCollected'] as num?)?.toDouble() ?? 0; + selectedOption = _paidMethod == 'upi' + ? 'UPI (₹${_paidAmount.toStringAsFixed(0)})' + : 'Cash (₹${_paidAmount.toStringAsFixed(0)})'; + }); + + // ── Step 3 of the v1 pickup flow: the money ── + // + // Sent before `_handleConfirm` because that posts `pickup-complete`, which + // is the pivot: it converts the booking into a consignment, and a payment + // against a booking that no longer exists is not a thing the backend can + // record. Until now the rider slid to confirm cash he was holding and + // nothing about it ever left the phone — so the only record that he had + // taken it was his own memory, on money he is personally accountable for. + // + // 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']; + if (bookingId != null) { + final payRes = await UpdatePickupProvider().submitPayment( + bookingId, + result, + ); + if (payRes?['status'] != true && mounted) { + AppFeedback.error( + context, + "Payment recorded on your phone but not with the hub — tell them " + "you collected ₹${_paidAmount.toStringAsFixed(0)}", + ); + } + } + + if (!mounted) return; + + // The slide on the payment page WAS the deliberate confirmation — it is the + // gesture this app reserves for money. Asking him to confirm the confirm + // would be the fifth tap this change exists to remove. + await _handleConfirm(); + } + + 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; + } + + // ── ALL ORIGINAL BUSINESS LOGIC BELOW — UNTOUCHED ──────────────────────── + + bool _wasSkippedPickup( + Map pickup, + _MyPickupsState? parentState, + ) { + final status = + (pickup['orderstatus'] ?? + pickup['status'] ?? + pickup['orderStatus'] ?? + '') + .toString() + .toLowerCase(); + if (status == 'skipped') return true; + if (parentState == null) return false; + final key = parentState._getOrderKey(pickup); + return _MyPickupsState._skippedOrdersCache.containsKey(key); + } + + Future _handleConfirm() async { + if (isLoading || isProcessing) { + debugPrint('[CONFIRM] Already processing or alert showing, ignoring tap'); + return; + } + + if (lastFailureTime != null) { + final timeSinceLastFailure = DateTime.now().difference(lastFailureTime!); + if (timeSinceLastFailure.inSeconds < 4) { + final wait = 4 - timeSinceLastFailure.inSeconds; + debugPrint( + '[CONFIRM] Too soon after last failure (${timeSinceLastFailure.inSeconds}s), ignoring tap', + ); + // Tell the rider WHY nothing happened instead of silently ignoring. + if (mounted) { + AppFeedback.info(context, 'Please wait ${wait}s and try again'); + } + return; + } + } + + final d = widget.pickup; + final bool isDelivery = stopKindOf(d).isDelivery; + debugPrint('[CONFIRM] ===== PICKUP INFO ====='); + debugPrint('[CONFIRM] pickupid: ${d['pickupid']}'); + debugPrint('[CONFIRM] orderid: ${d['orderid']}'); + debugPrint('[CONFIRM] orderstatus: ${d['orderstatus']}'); + + final parentState = + widget.parentState ?? + context.findAncestorStateOfType<_MyPickupsState>(); + final orderId = (d['orderid'] ?? '').toString(); + final pickupId = (d['pickupid'] ?? '').toString(); + + final bool wasSkipped = _wasSkippedPickup(d, parentState); + + if (parentState != null) { + final key = parentState._getOrderKey(d); + debugPrint('[CONFIRM] Generated key: $key'); + debugPrint( + '[CONFIRM] Is in cache: ${_MyPickupsState._skippedOrdersCache.containsKey(key)}', + ); + debugPrint( + '[CONFIRM] All cache keys: ${_MyPickupsState._skippedOrdersCache.keys.toList()}', + ); + } + debugPrint('[CONFIRM] wasSkipped=$wasSkipped'); + debugPrint('[CONFIRM] =========================='); + + // ── Hold the navigator, not the context ── + // + // This method awaits a location fix, an upload and an API call before it + // hands its result back, and it used to end with `if (!mounted) return;` + // before popping. If this State was disposed during any of those awaits — + // a rebuild of the route's builder is enough — the sheet's *route* was + // still on screen, so the rider saw the sheet sit there doing nothing after + // pressing Confirm, and the caller never received the outcome that tells it + // to open the next-stop screen. The work had been done; only the hand-back + // was lost. + // + // A NavigatorState captured before the first await survives that: the route + // gets popped with its result whether or not this widget is still alive. + final NavigatorState navigator = Navigator.of(context); + + if (mounted) { + setState(() { + isLoading = true; + isProcessing = true; + }); + } + + try { + final dc = Get.put(PickupsController(), permanent: true); + final int pickupIdInt = int.tryParse('${d['pickupid'] ?? 0}') ?? 0; + final int orderHeaderId = int.tryParse('${d['orderheaderid'] ?? 0}') ?? 0; + final int pickupLocationId = + int.tryParse('${d['pickuplocationid'] ?? 0}') ?? 0; + + bool apiSuccess = false; + + if (isPickupsSelected) { + double pickupAmount = _getPickupAmount(); + + if (pickupAmount == 0.0 && selectedOption.isNotEmpty) { + final m = RegExp(r'[0-9]+(?:\.[0-9]+)?').firstMatch(selectedOption); + if (m != null) { + pickupAmount = double.tryParse(m.group(0)!) ?? 0.0; + } + } + + // ── What the payment page recorded ── + // + // Read from [_paidMethod] / [_paidAmount] rather than parsed back out + // of a label. The method used to be recovered by string-matching the + // chip the rider had tapped ("Cash (₹450)" → startsWith('cash')), which + // meant the wire value depended on copy, and a short payment could not + // be expressed at all — the amount sent was always the full figure, + // whatever he had actually taken. + // + // status: 1 cash · 2 UPI · 3 nothing collected. + final double apiCollectionAmt = _getCollectionAmount(); + double collectionAmtToSend = 0.0; + double collectedAmtToSend = 0.0; + int collectionStatusToSend = 0; + + if (apiCollectionAmt > 0) { + collectionAmtToSend = apiCollectionAmt; + collectedAmtToSend = _paidAmount; + collectionStatusToSend = _paidAmount <= 0 + ? 3 + : (_paidMethod == 'upi' ? 2 : 1); + } + + 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); + + debugPrint( + '[PICKUP][CONFIRM] pickupId=$pickupIdInt orderHeaderId=$orderHeaderId pickupLocationId=$pickupLocationId pickupAmount=$pickupAmount notes="$selectedOption"', + ); + + // Best-effort rider location — no proof-photo camera step required. + 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('[PICKUP] Error getting rider location: $e'); + } + + // 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. + 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'); + + await _cachePickupsCancelled(pickupId, orderId, isPickups: true); + await _cleanupAfterCompletion( + orderId, + pickupId, + parentState, + isCancelled: false, + ); + } catch (e) { + debugPrint('[PICKUP][CONFIRM] API/cleanup error (ignored): $e'); + } + + // Pop the sheet and hand the outcome to the caller (a normal page + // route), which shows the success screen. Navigating from inside a + // modal sheet is unreliable, so the CALLER owns the navigation. + debugPrint('[PICKUP][CONFIRM] popping with outcome=completed'); + navigator.pop({ + 'outcome': 'completed', + 'isDelivery': isDelivery, + 'bonusPoints': dc.lastBonusPoints.value, + 'paymentMethod': selectedOption, + // What the payment page said was actually taken, which is not always + // what was due. Carried out so the completed record — and therefore + // Activity — can state it rather than infer it from the method chip. + 'amountCollected': collectedAmtToSend, + }); + 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'); + } + + // Best-effort cancel — never block on GPS, and never let an API/cleanup + // error stop the success screen. Proceed optimistically so the rider + // can always move to the next stop (matches the pickup path). + try { + apiSuccess = await dc.updateCancelledStatus( + pickupId: pickupIdInt, + orderHeaderId: orderHeaderId, + ridersLat: riderLatStr, + ridersLng: riderLngStr, + pickupLat: pickupLat.toStringAsFixed(6), + pickupLng: pickupLng.toStringAsFixed(6), + notes: selectedOption, + wasSkipped: wasSkipped, + ); + debugPrint('[CANCEL][CONFIRM] result=$apiSuccess'); + + await _cachePickupsCancelled(pickupId, orderId, isPickups: false); + await _cleanupAfterCompletion( + orderId, + pickupId, + parentState, + isCancelled: true, + ); + } catch (e) { + debugPrint('[CANCEL][CONFIRM] API/cleanup error (ignored): $e'); + } + + // Pop with the outcome; the caller shows the success screen. + debugPrint('[CANCEL][CONFIRM] popping with outcome=cancelled'); + navigator.pop({'outcome': 'cancelled', 'isDelivery': isDelivery}); + return; + } + } catch (e) { + debugPrint('[CONFIRM] Exception during confirm: $e'); + + lastFailureTime = DateTime.now(); + + if (mounted) { + setState(() { + isLoading = false; + }); + // Say so. This branch used to fail in complete silence and then lock + // 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"); + } + + await Future.delayed(const Duration(seconds: 4)); + + if (mounted) { + setState(() { + isProcessing = false; + }); + debugPrint('[CONFIRM] Button re-enabled after exception handling'); + } + } + } + + Future _cachePickupsCancelled( + String pickupId, + String orderId, { + required bool isPickups, + }) async { + try { + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + if (userId > 0) { + final key = 'recently_pickuped_cancelled_$userId'; + final pickedUpCancelledJson = prefs.getString(key); + List pickedUpCancelledList = []; + if (pickedUpCancelledJson != null && pickedUpCancelledJson.isNotEmpty) { + pickedUpCancelledList = jsonDecode(pickedUpCancelledJson); + } + + pickedUpCancelledList.add({ + 'pickupId': pickupId, + 'orderId': orderId, + 'timestamp': DateTime.now().toIso8601String(), + 'type': isPickups ? 'Picked up' : 'cancelled', + }); + + await prefs.setString(key, jsonEncode(pickedUpCancelledList)); + debugPrint( + '[${isPickups ? 'PICKUP' : 'CANCEL'}] Stored Booking in SharedPreferences: pickupId=$pickupId, orderId=$orderId', + ); + } + } catch (e) { + debugPrint( + '[${isPickups ? 'PICKUP' : 'CANCEL'}] Error storing Booking in SharedPreferences: $e', + ); + } + } + + Future _cleanupAfterCompletion( + String orderId, + String pickupId, + _MyPickupsState? parentState, { + required bool isCancelled, + }) async { + final label = isCancelled ? 'CANCEL' : 'PICKUP'; + if (orderId.isEmpty) { + debugPrint('[$label] Empty orderId, skipping cleanup'); + return; + } + + if (parentState == null) { + debugPrint( + '[$label] parentState null; relying on cache cleanup via SharedPreferences', + ); + try { + debugPrint('[$label] Cart page will cleanup timer on next refresh'); + } catch (_) {} + return; + } + + parentState._stopPickupPosting(orderId); + debugPrint('[$label] Stopped timer for completed pickup: $orderId'); + + final pickupKey = 'pickup_$pickupId'; + final orderKey = 'order_$orderId'; + final actualKey = parentState._getOrderKey(widget.pickup); + + bool cacheUpdated = false; + + void removeKey(String key, String reason) { + if (_MyPickupsState._skippedOrdersCache.remove(key) != null) { + _MyPickupsState._skippedOrderTimestamps.remove(key); + cacheUpdated = true; + debugPrint('[$label] Removed from cache ($reason): $key'); + } + } + + removeKey(pickupKey, 'pickup key'); + removeKey(orderKey, 'Booking key'); + removeKey(actualKey, 'actual key'); + + if (!cacheUpdated) { + final keysToRemove = []; + for (final key in keysToRemove) { + _MyPickupsState._skippedOrdersCache.remove(key); + _MyPickupsState._skippedOrderTimestamps.remove(key); + cacheUpdated = true; + debugPrint('[$label] Removed from cache (matched by ID): $key'); + } + } + + if (cacheUpdated) { + await parentState._saveSkippedOrdersCache(); + debugPrint( + '[$label] Cache saved. Remaining: ${_MyPickupsState._skippedOrdersCache.length}', + ); + } else { + debugPrint( + '[$label] Booking not found in skipped cache (cleanup will run on next fetch)', + ); + } + } +} + +/// ── Who the stop is, and where ── +/// +/// Three screens ask this: the map sheet before the rider sets off, Update +/// Status when he closes the stop, and Skip when he cannot. They had three +/// answers — two different avatar sizes, two different address treatments and, +/// on the skip sheet, no answer at all: it asked "Skip this pickup" without ever +/// naming which pickup. One widget, so they cannot drift again and so the skip +/// sheet gains the identification it was missing. +class _StopBrief extends StatelessWidget { + final Map pickup; + + /// Shown at the right of the identity row — a status pill on the map sheet, + /// nothing on the others. + final Widget? trailing; + + /// Closes the block with a hairline, when what follows is a different subject. + final bool showRule; + + const _StopBrief({ + required this.pickup, + this.trailing, + this.showRule = false, + }); + + @override + Widget build(BuildContext context) { + final kind = stopKindOf(pickup); + final name = + (pickup['PickupCustomer'] ?? + pickup['pickupcustomer'] ?? + pickup['Pickupcustomer'] ?? + 'Customer') + .toString() + .trim(); + final orderId = (pickup['orderid'] ?? pickup['OrderId'] ?? '') + .toString() + .trim(); + final address = + (pickup['pickupaddress'] ?? + pickup['PickupAddress'] ?? + pickup['Pickupaddress'] ?? + (kind.isDelivery ? 'Delivery location' : 'Pickup location')) + .toString() + .trim(); + + final initials = name.isNotEmpty + ? name + .split(' ') + .map((w) => w.isNotEmpty ? w[0] : '') + .take(2) + .join() + .toUpperCase() + : 'C'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 40.w, + height: 40.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: kind.accent.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Text( + initials, + style: TextStyle( + color: kind.accent, + fontSize: 13.5.sp, + fontWeight: FontWeight.w800, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name.isNotEmpty ? name : 'Customer', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + Text( + orderId.isNotEmpty ? 'Booking #$orderId' : 'Booking', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + if (trailing != null) ...[SizedBox(width: 10.w), trailing!], + ], + ), + SizedBox(height: 12.h), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Aligned to the avatar's column so the two rows read as one block + // 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, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + kind.locationLabel, + style: TextStyle( + fontSize: 9.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + color: kind.accent, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 3.h), + Text( + address.isNotEmpty ? address : 'Address not available', + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + height: 1.35, + fontWeight: FontWeight.w600, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + if (showRule) ...[ + SizedBox(height: 16.h), + Divider(height: 1, thickness: 1, color: ColorConstants.borderSubtle), + ], + ], + ); + } +} + +/// ── 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, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/Dashboard/pickups/skip_sheet.dart b/lib/views/Dashboard/pickups/skip_sheet.dart new file mode 100644 index 0000000..677bc0e --- /dev/null +++ b/lib/views/Dashboard/pickups/skip_sheet.dart @@ -0,0 +1,405 @@ +part of 'pickups.dart'; + +Future _showMyOptionsSheet( + BuildContext context, + Map pickup, + _MyPickupsState? parentState, +) async { + int selected = -1; + bool isLoading = false; + + final List skipReasons = [ + 'Customer unreachable', + 'Customer not at the location', + ]; + + await showModalBottomSheet( + context: context, + sheetAnimationStyle: kMilerSheetStyle, + isScrollControlled: true, + backgroundColor: Colors.transparent, + 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), + ); + } + + 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; + + 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, + ), + ), + 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( + children: [ + Expanded( + child: MilerButton( + label: "Cancel", + onPressed: () => + Navigator.pop(context, false), + variant: MilerButtonVariant.outlined, + color: ColorConstants.onSurfaceVariant, + height: ButtonSizes.secondary, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: MilerButton( + label: "Confirm skip", + onPressed: () => + Navigator.pop(context, true), + color: ColorConstants.errorRed, + height: ButtonSizes.secondary, + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + + 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, + 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, + ), + ), + 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: 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, + ), + ], + ), + ), + ); + }, + ), + ); + }, + ); +} diff --git a/lib/views/Dashboard/pickups/stop_type.dart b/lib/views/Dashboard/pickups/stop_type.dart new file mode 100644 index 0000000..a46ba87 --- /dev/null +++ b/lib/views/Dashboard/pickups/stop_type.dart @@ -0,0 +1,309 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; + +/// Mixed pickup + delivery routes (Amazon/Flipkart-style), including stops +/// where the rider must do BOTH at the same address. +/// +/// A route stop is one of three operational modes: +/// +/// • **pickup** — collect parcels from the customer. Photo + weight proof. +/// May carry COD (cash / UPI-QR). +/// • **delivery** — hand over a parcel carried from the hub. OTP + photo +/// proof. Prepaid, so nothing is collected. +/// • **combined** — one address, both jobs. e.g. hand over 1 parcel AND +/// collect 2 returns. This is a single stop with a two-leg +/// flow, NOT two stops — the rider parks once. +/// +/// 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. +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. +/// 4. Pickup — the app's original behaviour. +StopKind stopKindOf(Map item) { + final raw = _rawType(item); + + if (raw == 'combined' || + raw == 'both' || + raw == 'pickup_delivery' || + raw == 'pickupdelivery' || + raw == 'delivery_pickup') { + return StopKind.combined; + } + if (raw == 'delivery' || raw == 'deliver' || raw == 'drop') { + return StopKind.delivery; + } + if (raw == 'pickup' || raw == 'pick' || raw == 'collect') { + return StopKind.pickup; + } + + // Structural inference — trust the payload when the label is missing. + if (_explicitDeliveryQty(item) > 0 && _explicitPickupQty(item) > 0) { + 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; + } + } + return StopKind.pickup; +} + +String _rawType(Map item) => + (item['type'] ?? item['stoptype'] ?? item['stopType'] ?? '') + .toString() + .trim() + .toLowerCase(); + +int _explicitDeliveryQty(Map item) => _toInt( + item['deliveryqty'] ?? + item['deliveryQty'] ?? + item['dropqty'] ?? + item['deliverycount'], +); + +int _explicitPickupQty(Map item) => _toInt( + item['pickupqty'] ?? + item['pickupQty'] ?? + item['collectqty'] ?? + item['returnqty'], +); + +/// Parcels to be HANDED OVER at this stop (delivery leg). Zero for a +/// pickup-only stop. +int deliveryParcelCount(Map item) { + final explicit = _explicitDeliveryQty(item); + if (explicit > 0) return explicit; + + // No explicit split: a delivery-labelled stop delivers its whole quantity. + return stopKindOf(item) == StopKind.delivery + ? _toInt(item['Quantity'] ?? item['quantity']) + : 0; +} + +/// Parcels to be COLLECTED at this stop (pickup leg). Zero for a +/// delivery-only stop. +int pickupParcelCount(Map item) { + final explicit = _explicitPickupQty(item); + if (explicit > 0) return explicit; + + return stopKindOf(item) == StopKind.delivery + ? 0 + : _toInt(item['Quantity'] ?? item['quantity']); +} + +/// Cash still to be collected at this stop, in rupees. Deliveries are prepaid, +/// so this only ever applies to a pickup leg. +double stopCollectionAmount(Map item) { + final v = item['collectionamt'] ?? item['pickupamt'] ?? item['codamount']; + if (v == null) return 0; + if (v is num) return v.toDouble(); + final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), ''); + return double.tryParse(cleaned) ?? 0; +} + +/// The delivery OTP, when the stop has a delivery leg. +String stopOtp(Map item) => + (item['otp'] ?? item['Otp'] ?? item['deliveryotp'] ?? '').toString().trim(); + +int _toInt(dynamic v) { + if (v == null) return 0; + if (v is num) return v.toInt(); + return int.tryParse(v.toString().trim()) ?? 0; +} + +extension StopKindUi on StopKind { + /// True only for a delivery-ONLY stop. + /// + /// Deliberately narrow. The confirm/payment path keys off this to mean + /// "prepaid, no money, OTP required". A combined stop still has a pickup leg + /// that can carry COD, so it must NOT take the delivery-only path — ask + /// [hasDelivery] / [hasPickup] about legs instead. + bool get isDelivery => this == StopKind.delivery; + + bool get isCombined => this == StopKind.combined; + + /// Does this stop involve handing a parcel over? (delivery + combined) + bool get hasDelivery => + this == StopKind.delivery || this == StopKind.combined; + + /// Does this stop involve collecting a parcel? (pickup + combined) + bool get hasPickup => this == StopKind.pickup || this == StopKind.combined; + + /// The legs to run, in execution order. + /// + /// Deliver first on a combined stop: the rider walks up with the outbound + /// parcel already in his hands, so he hands it over before taking anything + /// back. Reversing this makes him juggle two loads at the doorstep. + List get legs => switch (this) { + StopKind.pickup => const [StopLeg.pickup], + StopKind.delivery => const [StopLeg.delivery], + StopKind.combined => const [StopLeg.delivery, StopLeg.pickup], + }; + + /// Distinct accent per mode so the three are never confused at a glance. + Color get accent => switch (this) { + StopKind.pickup => ColorConstants.pickupAccent, + StopKind.delivery => ColorConstants.deliveryAccent, + StopKind.combined => ColorConstants.combinedAccent, + }; + + /// Fill for the small chip that names this mode, carrying white text. + /// + /// 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, + }; + + IconData get icon => switch (this) { + StopKind.pickup => Icons.inventory_2_rounded, + StopKind.delivery => Icons.local_shipping_rounded, + StopKind.combined => Icons.swap_horiz_rounded, + }; + + /// Short badge label. + String get badgeLabel => 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. + /// + /// [badgeLabel] shouts because it is a filled mark competing with a card full + /// 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', + }; + + /// Label for the small chip on a stop card — **both** stop cards. + /// + /// "PICKUP & DELIVERY" is 17 characters. On a stop row it shares a line with a + /// duration, a call button and a tick box, all of which have a fixed claim on + /// the width — so it was ellipsising down to "P…" and telling the rider + /// nothing at all. A chip that has to truncate is not a chip. + /// + /// "P&D" fits at any width and, unlike the "BOTH" it replaces, still names + /// the two jobs rather than merely counting them: "BOTH" only means anything + /// if you already know what the two things are, which is exactly what a rider + /// glancing at an unfamiliar stop does not. + /// + /// The Bookings card used [badgeLabel] here and now uses this, so the two + /// 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', + }; + + /// Location-row label. + String get locationLabel => switch (this) { + StopKind.pickup => 'PICKUP LOCATION', + StopKind.delivery => 'DELIVER TO', + StopKind.combined => 'DELIVER & COLLECT AT', + }; + + /// Verb for inline CTAs — "start pickup" / "start delivery" / … + String get startVerb => switch (this) { + StopKind.pickup => 'start pickup', + StopKind.delivery => 'start delivery', + StopKind.combined => 'start combined stop', + }; + + /// Full primary-CTA text on the stop card. + String get startCtaLabel => switch (this) { + StopKind.pickup => 'Start Pickup', + StopKind.delivery => 'Start Delivery', + StopKind.combined => 'Start Combined Stop', + }; + + /// Success verb once the WHOLE stop is finished. + String get completeLabel => switch (this) { + StopKind.pickup => 'Picked up', + StopKind.delivery => 'Delivered', + StopKind.combined => 'Complete Stop', + }; + + /// Failure / abort verb. + String get failLabel => switch (this) { + StopKind.pickup => 'Cancel', + StopKind.delivery => 'Undelivered', + StopKind.combined => 'Could not complete', + }; + + /// Noun used on the success screen. + String get noun => switch (this) { + StopKind.pickup => 'Pickup', + StopKind.delivery => 'Delivery', + StopKind.combined => 'Stop', + }; + + /// Wire value carried in the completion outcome map. + String get wireName => switch (this) { + StopKind.pickup => 'pickup', + StopKind.delivery => 'delivery', + StopKind.combined => 'combined', + }; +} + +/// Parse a [StopKind] back from [StopKindUi.wireName]. Unknown → pickup, the +/// app's safe default. +StopKind stopKindFromWire(dynamic raw) => switch (raw?.toString()) { + 'delivery' => StopKind.delivery, + 'combined' => StopKind.combined, + _ => StopKind.pickup, +}; + +extension StopLegUi on StopLeg { + bool get isDelivery => this == StopLeg.delivery; + + String get title => isDelivery ? 'Hand over parcel' : 'Collect parcel'; + + String get proofHint => + isDelivery ? 'OTP + photo required' : 'Photo + weight required'; + + IconData get icon => + isDelivery ? Icons.local_shipping_rounded : Icons.inventory_2_rounded; + + 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 new file mode 100644 index 0000000..aa1c169 --- /dev/null +++ b/lib/views/Dashboard/pickups/stop_verify.dart @@ -0,0 +1,2301 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:image_picker/image_picker.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 VERIFICATION — proof of work at the door, one leg at a time. +/// +/// The old page had a single form with one boolean, `_isDelivery`, flipping a +/// few labels. That boolean is `StopKind.isDelivery`, which is deliberately +/// narrow — true only for a delivery-ONLY stop — so a COMBINED stop fell to +/// the else branch and was verified as if it were a plain pickup: one photo, +/// one "pickup code", and no evidence whatsoever that the outbound parcel was +/// ever handed over. `StopKind.legs` existed to describe exactly this and had +/// no caller anywhere in the app. +/// +/// So verification is now built on the legs, not on a boolean: +/// +/// pickup → Collect → Confirm +/// delivery → Hand over → Confirm +/// combined → Hand over → Collect → Confirm +/// +/// The rider parks once and works the door once, in that order — deliver +/// first, because he walks up with the outbound parcel already in his hands. +/// +/// What each leg asks for follows Amazon Flex / Flipkart Wishmaster practice, +/// trimmed to what this backend can actually carry: +/// +/// • **Hand over** — tick off each parcel as it leaves your hands, say who +/// received it, OTP from the customer, photo. Ticking parcels individually +/// is the one thing that stops a three-parcel drop being closed after two; +/// a single "delivered" button cannot catch that. +/// +/// • **Collect** — count what you actually took (not what the manifest +/// claims), flag the packaging condition, weight, photo, code. A count +/// below the expected one demands a note: that discrepancy is the whole +/// reason first-mile pickups get disputed later. +/// +/// The page returns the legacy result map the rest of the flow already reads +/// (`verified` / `parcelImage` / `weight` / `otp` / `description` / +/// `dropAddress`) plus a `delivery` and `pickup` sub-map, so `map.dart`, +/// `homepage.dart` and `PaymentScreen` keep working untouched. +/// ───────────────────────────────────────────────────────────────────────── +class StopVerificationPage extends StatefulWidget { + final Map pickup; + + const StopVerificationPage({super.key, required this.pickup}); + + @override + State createState() => _StopVerificationPageState(); +} + +/// Who actually took the parcel. Anything other than [customer] is a handover +/// to a third party, which is where delivery disputes come from — so it forces +/// a name and a photo. +enum _Recipient { customer, family, security, neighbour } + +extension _RecipientUi on _Recipient { + String get label => switch (this) { + _Recipient.customer => 'Customer', + _Recipient.family => 'Family', + _Recipient.security => 'Security', + _Recipient.neighbour => 'Neighbour', + }; + + 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, + }; + + /// A third party took it → we need their name on record. + bool get needsName => this != _Recipient.customer; + + String get wireName => name; +} + +/// Condition of what is being collected. Recorded at the door because it can +/// never be established afterwards. +enum _Condition { sealed, unpacked, damaged } + +extension _ConditionUi on _Condition { + String get label => switch (this) { + _Condition.sealed => 'Sealed & intact', + _Condition.unpacked => 'Not packed', + _Condition.damaged => 'Damaged', + }; + + IconData get icon => switch (this) { + _Condition.sealed => Icons.verified_rounded, + _Condition.unpacked => Icons.inventory_rounded, + _Condition.damaged => Icons.report_problem_rounded, + }; + + Color get accent => switch (this) { + _Condition.sealed => ColorConstants.acceptGreen, + _Condition.unpacked => ColorConstants.warning, + _Condition.damaged => ColorConstants.errorRed, + }; + + /// Anything but a clean seal needs the rider to say what he saw. + bool get needsNote => this != _Condition.sealed; + + String get wireName => name; +} + +class _StopVerificationPageState extends State { + // ── The stop ── + late final StopKind _kind; + late final List _legs; + late final String _customerName; + late final String _address; + late final int _expectedDelivery; + late final int _expectedPickup; + late final double _cash; + + final ImagePicker _picker = ImagePicker(); + + /// Current step. `_legs.length` is the review step, so it is always last. + int _step = 0; + + // ── Delivery leg ── + final Set _handedOver = {}; + _Recipient _recipient = _Recipient.customer; + final TextEditingController _recipientName = TextEditingController(); + final TextEditingController _deliveryOtp = TextEditingController(); + File? _deliveryPhoto; + + // ── Pickup leg ── + late int _collected; + _Condition _condition = _Condition.sealed; + final TextEditingController _conditionNote = TextEditingController(); + final TextEditingController _weight = TextEditingController(); + final TextEditingController _pickupCode = TextEditingController(); + File? _pickupPhoto; + + bool _confirming = false; + + @override + void initState() { + super.initState(); + final d = widget.pickup; + _kind = stopKindOf(d); + _legs = _kind.legs; + _customerName = (d['pickupcustomer'] ?? d['PickupCustomer'] ?? 'Customer') + .toString(); + _address = (d['pickupaddress'] ?? d['PickupAddress'] ?? 'Stop location') + .toString(); + _cash = stopCollectionAmount(d); + + // A leg that exists always has at least one parcel — a stop that hands + // over nothing is not a delivery leg. + _expectedDelivery = _kind.hasDelivery + ? deliveryParcelCount(d).clamp(1, 99) + : 0; + _expectedPickup = _kind.hasPickup ? pickupParcelCount(d).clamp(1, 99) : 0; + + // Pre-set to the manifest count: the common case is that it matches, and + // making the rider tap up from zero every time would train him to stop + // reading the number. + _collected = _expectedPickup; + } + + @override + void dispose() { + _recipientName.dispose(); + _deliveryOtp.dispose(); + _conditionNote.dispose(); + _weight.dispose(); + _pickupCode.dispose(); + super.dispose(); + } + + // ── Completion rules ─────────────────────────────────────────────────── + + /// Every parcel ticked, a name when someone other than the customer took it, + /// an OTP, and a photo. + bool get _deliveryDone => + _handedOver.length == _expectedDelivery && + (!_recipient.needsName || _recipientName.text.trim().length >= 2) && + _deliveryOtp.text.trim().length >= 4 && + _deliveryPhoto != null; + + /// A short count or a bad seal must be explained; the photo is always + /// required. The code is required on a pickup-only stop — on a combined stop + /// the delivery OTP has already proved the rider was at the right door, and + /// asking for a second code at the same doorstep is friction that buys + /// nothing. + /// + /// ── Weight is required ── + /// + /// It was optional, on the reasoning that a rider without a scale should not + /// be blocked. But weight is what the consignment is *billed* on: it leaves + /// the door once, and if it is not captured here nobody can establish it + /// afterwards — the parcel is already in the bag, then in a van, then in a + /// sorting hub. A blank field silently pushed that dispute onto the office + /// weeks later, and onto the rider who took it. + bool get _pickupDone => + _pickupPhoto != null && + _weightEntered && + (!_needsPickupNote || _conditionNote.text.trim().length >= 3) && + (_kind.isCombined || _pickupCode.text.trim().length >= 4); + + /// A weight that is actually a weight: present, numeric and above zero. + /// "0" is what gets typed to get past a required field, and it is not a + /// measurement. + bool get _weightEntered { + final v = double.tryParse(_weight.text.trim()); + return v != null && v > 0; + } + + bool get _needsPickupNote => + _condition.needsNote || _collected != _expectedPickup; + + bool _legDone(StopLeg leg) => leg.isDelivery ? _deliveryDone : _pickupDone; + + bool get _allDone => _legs.every(_legDone); + + /// What is missing on the step being shown, phrased as the next thing to do. + String? get _blockingHint { + if (_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. + final unfinished = _legs.firstWhere((l) => !_legDone(l)); + return unfinished.isDelivery + ? 'Go back — the hand-over is not finished' + : 'Go back — the collection is not finished'; + } + if (_legs[_step].isDelivery) { + if (_handedOver.length != _expectedDelivery) { + final left = _expectedDelivery - _handedOver.length; + // States the shortfall rather than issuing an instruction. "Tick 1 more + // parcel as handed over" describes the control; "1 parcel still to hand + // over" describes the doorstep, which is where the rider's attention is + // and the only place he can fix it. + return '$left ${left == 1 ? 'parcel' : 'parcels'} still to hand over'; + } + if (_recipient.needsName && _recipientName.text.trim().length < 2) { + return 'Enter the name of the person who took it'; + } + if (_deliveryOtp.text.trim().length < 4) { + return 'Enter the OTP from the customer'; + } + if (_deliveryPhoto == null) return 'Take a proof photo'; + return null; + } + if (_pickupPhoto == null) return 'Take a photo of the parcels'; + if (!_weightEntered) return 'Enter the total weight in kg'; + if (_needsPickupNote && _conditionNote.text.trim().length < 3) { + return _collected != _expectedPickup + ? 'Say why the count does not match' + : 'Describe the condition you found'; + } + if (!_kind.isCombined && _pickupCode.text.trim().length < 4) { + return 'Enter the pickup code from the customer'; + } + return null; + } + + // ── Actions ──────────────────────────────────────────────────────────── + + Future _capture({required bool forDelivery}) async { + try { + final shot = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 85, + ); + if (shot == null || !mounted) return; + setState(() { + if (forDelivery) { + _deliveryPhoto = File(shot.path); + } else { + _pickupPhoto = File(shot.path); + } + }); + } catch (_) { + if (!mounted) return; + _toast('Could not open the camera'); + } + } + + /// Every message on this screen goes through the one feedback shape. + /// See [AppFeedback]. + void _toast(String message) => AppFeedback.error(context, message); + + + void _next() { + final hint = _blockingHint; + if (hint != null) { + _toast(hint); + return; + } + FocusScope.of(context).unfocus(); + setState(() => _step = (_step + 1).clamp(0, _legs.length)); + } + + void _back() { + FocusScope.of(context).unfocus(); + if (_step == 0) { + Navigator.pop(context); + return; + } + setState(() => _step -= 1); + } + + void _confirm() { + if (!_allDone) { + _toast(_blockingHint ?? 'Finish every step to confirm'); + return; + } + setState(() => _confirming = true); + + // The proof image the rest of the flow uploads. On a combined stop that is + // the pickup shot — it is what evidences the parcels now in the rider's + // bag, which is the leg the backend records as 'picked'. + final File? primaryPhoto = _pickupPhoto ?? _deliveryPhoto; + final String primaryCode = _kind.hasDelivery + ? _deliveryOtp.text.trim() + : _pickupCode.text.trim(); + + Navigator.pop(context, { + // Legacy contract — map.dart, homepage.dart and PaymentScreen read these. + 'verified': true, + 'parcelImage': primaryPhoto?.path, + 'weight': _weight.text.trim(), + 'description': _conditionNote.text.trim(), + 'dropAddress': + (widget.pickup['dropaddress'] ?? widget.pickup['DropAddress'] ?? '') + .toString(), + 'otp': primaryCode, + + // Leg-scoped detail. New consumers should read these rather than + // guessing which leg the flat keys above came from. + 'stopKind': _kind.wireName, + if (_kind.hasDelivery) + 'delivery': { + 'parcels': _expectedDelivery, + 'handedOver': _handedOver.length, + 'receivedBy': _recipient.wireName, + 'receivedByName': _recipient.needsName + ? _recipientName.text.trim() + : _customerName, + 'otp': _deliveryOtp.text.trim(), + 'photo': _deliveryPhoto?.path, + }, + if (_kind.hasPickup) + 'pickup': { + 'expected': _expectedPickup, + 'collected': _collected, + 'shortBy': _expectedPickup - _collected, + 'condition': _condition.wireName, + 'note': _conditionNote.text.trim(), + 'weight': _weight.text.trim(), + 'code': _pickupCode.text.trim(), + 'photo': _pickupPhoto?.path, + }, + }); + } + + // ── Build ────────────────────────────────────────────────────────────── + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: _step == 0, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _back(); + }, + // ── White page, tinted panels ── + // + // Dropping the borders left the sections floating: a white card on a + // near-white page has no edge at all, so five panels read as one long + // undifferentiated form. Inverting it is what the Home stop card already + // does — the page is white and the card carries the fill — and it is the + // only way to separate surfaces without drawing a line around each one. + // See [ColorConstants.cardSurface] for why the fill sits 9.4% off white + // rather than the ~3% a "subtle" grey would give: this screen is filled + // in outdoors. + child: Scaffold( + backgroundColor: ColorConstants.pureSurface, + // ── The bar breathes, and its title is a title ── + // + // 17sp jammed against the status bar is a system default, not a + // decision: on a screen the rider opens at a doorstep with the phone at + // arm's length, the one word telling him which job he is verifying was + // the smallest text on it. Taller bar, type at 22, and the whole thing + // pushed clear of the notch — the same proportions Uber's task screens + // use, and for the same reason. + // + // The 22h top inset is the part that was missing. `toolbarHeight` alone + // centres its content, so raising the bar moved the title *down by half* + // what was added and left it sitting right under the clock — which is + // where a system default puts it, and the reason this looked cramped + // against every other page in the app. The inset pushes it the rest of + // the way, to roughly where `appTopBar`'s heading lands on the Account + // pages. + appBar: milerPageBar(_title, onBack: _back), + body: Column( + children: [ + _stepper(), + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.fromLTRB(20.w, 16.h, 20.w, 24.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _stopSummary(), + if (_step >= _legs.length) ...[ + SizedBox(height: 22.h), + Container( + height: 1, + color: ColorConstants.borderSubtle, + ), + SizedBox(height: 20.h), + _reviewStep(), + ] else if (_legs[_step].isDelivery) + _deliveryStep() + else + _pickupStep(), + ], + ), + ), + ), + _bottomBar(), + ], + ), + ), + ); + } + + String get _title => switch (_kind) { + StopKind.pickup => 'Verify pickup', + StopKind.delivery => 'Verify delivery', + StopKind.combined => 'Verify stop', + }; + + // ── Stepper ──────────────────────────────────────────────────────────── + // + // Names the legs rather than numbering them. "Step 2 of 3" tells the rider + // nothing he can act on; "Collect" tells him what he is at the door to do, + // and on a combined stop it is the only thing on screen that says a second + // job is still waiting after this one. + Widget _stepper() { + final labels = [ + for (final leg in _legs) leg.isDelivery ? 'Hand over' : 'Collect', + 'Confirm', + ]; + final accents = [ + for (final leg in _legs) leg.accent, + ColorConstants.acceptGreen, + ]; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + // Belongs to the bar above it, not to the form below. Without the rule + // it read as a first section of the page, which put a progress display + // — the one thing on screen the rider never touches — at the head of a + // list of things he does. + border: Border( + bottom: BorderSide(color: ColorConstants.borderSubtle, width: 1), + ), + ), + padding: EdgeInsets.fromLTRB(20.w, 4.h, 20.w, 11.h), + // ── Every part gives ground ── + // + // The nodes used to take their natural width with only the connectors + // flexible. Three named steps — "Hand over", "Collect", "Confirm" — are + // wider than a 360pt phone from about 1.4x system text upward, so a + // combined stop overflowed the row and struck the red-and-yellow bar + // across the top of the page, on every step including the one the rider + // is signing. Flexible nodes cost nothing at 1.0x, where each still takes + // its natural width. + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(labels.length * 2 - 1, (i) { + if (i.isOdd) { + final done = i ~/ 2 < _step; + return Expanded( + child: Container( + height: 2.h, + // The connector is drawn against the circle, not the label, so + // it sits at the circle's mid-line rather than in the middle of + // a node that may now be two lines tall. + margin: EdgeInsets.fromLTRB(6.w, 11.5.w, 6.w, 0), + color: done + ? ColorConstants.acceptGreen + : ColorConstants.borderSubtle, + ), + ); + } + final idx = i ~/ 2; + final current = idx == _step; + final past = idx < _step; + final accent = accents[idx]; + + return Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 220), + width: 25.w, + height: 25.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: past + ? ColorConstants.acceptGreen + : current + ? accent + : ColorConstants.pureSurface, + shape: BoxShape.circle, + border: Border.all( + color: past + ? ColorConstants.acceptGreen + : current + ? accent + : ColorConstants.borderStrong, + width: 2, + ), + ), + child: past + ? Icon( + Icons.check_rounded, + size: 15.sp, + color: Colors.white, + ) + : Text( + '${idx + 1}', + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w800, + color: current + ? Colors.white + : ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(height: 3.h), + // Wraps rather than ellipsises: "Hand over" broken across two + // lines still says what the step is. "Hand o…" does not, and a + // step label the rider cannot read is the same as no stepper. + Text( + labels[idx], + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.5.sp, + height: 1.2, + fontWeight: current ? FontWeight.w800 : FontWeight.w600, + color: past + ? ColorConstants.acceptGreen + : current + ? accent + : ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + }), + ), + ); + } + + // ── Stop summary ─────────────────────────────────────────────────────── + // + // Two different kinds of thing share this screen, and they were being drawn + // at the same rank. The stop — who, where, how much — is *context*: the rider + // read it once on the map screen, rode here on it, and is looking at the + // building it names. The sections below are *work*. + // + // As a filled panel it looked like a fifth thing to fill in, and it was the + // first thing on the page, so every leg opened by drawing the eye to the one + // block that needs nothing done to it. Unboxed it reads as a heading, which + // is what it is. + Widget _stopSummary() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Both halves give ground, neither is cut off ── + // + // A fixed badge, a `Spacer` and a fixed amount: the row was wider + // than a 360pt phone from 1.3x system text upward, and the rider who + // has turned his font up is the one who most needs to read "₹2450 to + // collect". Flexible on both sides costs nothing at normal size — + // each still takes its natural width — and lets the longer one + // ellipsise instead of striping the card black and yellow. + Row( + children: [ + // The stop type as a dotted label rather than a filled badge. It + // is an attribute of the name below it, and a saturated chip made + // it the loudest thing on a screen whose subject is the form. + Container( + width: 6.w, + height: 6.w, + margin: EdgeInsets.only(right: 8.w), + decoration: BoxDecoration( + color: _kind.accent, + shape: BoxShape.circle, + ), + ), + Flexible( + child: Text( + _kind.titleLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w800, + color: _kind.accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + const Spacer(), + if (_cash > 0) + Flexible( + child: Text( + '₹${_cash.toStringAsFixed(0)} to collect', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w800, + color: ColorConstants.acceptGreen, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + SizedBox(height: 8.h), + Text( + _customerName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 19.sp, + fontWeight: FontWeight.w800, + 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: 12.h), + // Wrap rather than Row: on a combined stop the two pills together are + // wider than the page once the system font is up, and the second one + // belongs on its own line rather than off the edge of the screen. + Wrap( + spacing: 7.w, + runSpacing: 7.h, + children: [ + if (_expectedDelivery > 0) + _loadPill( + Icons.local_shipping_rounded, + 'Hand over $_expectedDelivery', + ColorConstants.deliveryAccent, + ), + if (_expectedPickup > 0) + _loadPill( + Icons.inventory_2_rounded, + 'Collect $_expectedPickup', + ColorConstants.pickupAccent, + ), + ], + ), + ], + ); + } + + Widget _loadPill(IconData icon, String label, Color accent) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 6.h), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13.sp, color: accent), + SizedBox(width: 5.w), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w800, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } + + // ── Delivery leg ─────────────────────────────────────────────────────── + Widget _deliveryStep() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _section( + 'Hand over the parcels', + // Counts up once there is more than one to count. On a three-parcel + // drop "tick each one" is the instruction and "2 of 3 ticked" is the + // progress, and the rider standing at the door needs the second more + // than the first by the time he is half-way through. + _expectedDelivery > 1 + ? 'Tick each one as it leaves your hands · ' + '${_handedOver.length} of $_expectedDelivery ticked' + : 'Tick it as it leaves your hands', + done: _handedOver.length == _expectedDelivery, + child: Column( + children: [ + for (var i = 0; i < _expectedDelivery; i++) ...[ + if (i > 0) SizedBox(height: 8.h), + _parcelTile(i), + ], + ], + ), + ), + _section( + 'Who received it?', + _recipient.needsName + ? 'A third party took it — record their name' + : null, + done: !_recipient.needsName || _recipientName.text.trim().length >= 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _choiceGrid( + count: _Recipient.values.length, + chips: (width, lastWidth) => [ + for (final (i, r) in _Recipient.values.indexed) + _choiceChip( + label: r.label, + icon: r.icon, + selected: _recipient == r, + accent: ColorConstants.deliveryAccent, + width: i == _Recipient.values.length - 1 + ? lastWidth + : width, + onTap: () => setState(() => _recipient = r), + ), + ], + ), + if (_recipient.needsName) ...[ + SizedBox(height: 12.h), + _field( + controller: _recipientName, + hint: "Person's name", + icon: Icons.badge_rounded, + capitalization: TextCapitalization.words, + ), + ], + ], + ), + ), + _section( + 'Delivery OTP', + 'Ask the customer to read it out', + done: _deliveryOtp.text.trim().length >= 4, + child: _CodeBoxes( + controller: _deliveryOtp, + length: 6, + accent: ColorConstants.deliveryAccent, + onChanged: () => setState(() {}), + ), + ), + _section( + 'Proof photo', + 'The parcel at the door, or with the person who took it', + done: _deliveryPhoto != null, + child: _photoBox( + file: _deliveryPhoto, + accent: ColorConstants.deliveryAccent, + onTap: () => _capture(forDelivery: true), + onClear: () => setState(() => _deliveryPhoto = null), + ), + ), + ], + ); + } + + Widget _parcelTile(int index) { + final ticked = _handedOver.contains(index); + return PressScale( + onTap: () => setState(() { + if (!_handedOver.remove(index)) _handedOver.add(index); + }), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 14.h), + decoration: BoxDecoration( + color: ticked + ? ColorConstants.acceptGreen.withValues(alpha: 0.10) + : ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + // The tick is the whole point of this control, so the untouched state + // has to look pressable. On the old grey panel the white fill did + // that; on a white page only an edge can. + border: Border.all( + color: ticked + ? ColorConstants.acceptGreen.withValues(alpha: 0.45) + : ColorConstants.borderStrong, + width: 1.4, + ), + ), + child: Row( + children: [ + Icon( + ticked + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked_rounded, + size: 21.sp, + color: ticked + ? ColorConstants.acceptGreen + : ColorConstants.borderStrong, + ), + SizedBox(width: 10.w), + Expanded( + child: Text( + 'Parcel ${index + 1} of $_expectedDelivery', + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w700, + 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, + ), + ), + ], + ), + ), + ); + } + + // ── Pickup leg ───────────────────────────────────────────────────────── + Widget _pickupStep() { + final short = _collected != _expectedPickup; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _section( + 'Parcels collected', + 'Count what you actually took', + done: true, + child: Column( + children: [ + _counter(), + if (short) ...[ + SizedBox(height: 11.h), + _notice( + _collected < _expectedPickup + ? '${_expectedPickup - _collected} fewer than the ' + '${_expectedPickup == 1 ? 'parcel' : 'parcels'} booked' + : '${_collected - _expectedPickup} more than booked', + ColorConstants.warning, + ), + ], + ], + ), + ), + _section( + 'Condition', + 'Recorded now — it cannot be proved later', + done: !_condition.needsNote, + child: _choiceGrid( + count: _Condition.values.length, + chips: (width, lastWidth) => [ + for (final (i, c) in _Condition.values.indexed) + _choiceChip( + label: c.label, + icon: c.icon, + selected: _condition == c, + accent: c.accent, + width: i == _Condition.values.length - 1 ? lastWidth : width, + onTap: () => setState(() => _condition = c), + ), + ], + ), + ), + if (_needsPickupNote) ...[ + _section( + 'What happened?', + 'Required — this is what settles a dispute later', + done: _conditionNote.text.trim().length >= 3, + 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, + maxLines: 3, + capitalization: TextCapitalization.sentences, + ), + ), + ], + _section( + 'Weight', + 'The consignment is billed on this — it cannot be established later', + done: _weightEntered, + child: _field( + controller: _weight, + hint: 'Total weight in kg', + icon: Icons.scale_rounded, + keyboard: const TextInputType.numberWithOptions(decimal: true), + formatters: [FilteringTextInputFormatter.allow(RegExp(r'[0-9.]'))], + ), + ), + _section( + 'Parcel photo', + 'All $_collected ${_collected == 1 ? 'parcel' : 'parcels'} in one shot', + done: _pickupPhoto != null, + child: _photoBox( + file: _pickupPhoto, + accent: ColorConstants.pickupAccent, + onTap: () => _capture(forDelivery: false), + onClear: () => setState(() => _pickupPhoto = null), + ), + ), + _section( + 'Pickup code', + _kind.isCombined + ? 'Optional — the delivery OTP already verified this door' + : 'Ask the customer to read it out', + done: _kind.isCombined || _pickupCode.text.trim().length >= 4, + optional: _kind.isCombined, + child: _CodeBoxes( + controller: _pickupCode, + length: 6, + accent: ColorConstants.pickupAccent, + onChanged: () => setState(() {}), + ), + ), + ], + ); + } + + Widget _counter() { + Widget btn(IconData icon, VoidCallback? onTap) { + final enabled = onTap != null; + return PressScale( + onTap: onTap, + child: Container( + width: 46.w, + height: 46.w, + decoration: BoxDecoration( + color: enabled + ? ColorConstants.pickupAccent.withValues(alpha: 0.10) + : ColorConstants.neutralLight, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + border: Border.all( + color: enabled + ? ColorConstants.pickupAccent.withValues(alpha: 0.35) + : ColorConstants.borderSubtle, + width: 1.5, + ), + ), + child: Icon( + icon, + size: 21.sp, + color: enabled + ? ColorConstants.pickupAccent + : ColorConstants.disabledFill, + ), + ), + ); + } + + return Row( + children: [ + btn( + Icons.remove_rounded, + _collected > 0 ? () => setState(() => _collected -= 1) : null, + ), + Expanded( + child: Column( + children: [ + Text( + '$_collected', + style: TextStyle( + fontSize: 30.sp, + fontWeight: FontWeight.w800, + letterSpacing: -1, + height: 1.1, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + Text( + 'of $_expectedPickup booked', + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + btn( + Icons.add_rounded, + _collected < 99 ? () => setState(() => _collected += 1) : null, + ), + ], + ); + } + + // ── Review ───────────────────────────────────────────────────────────── + // + // ── Why this screen is not another form panel ── + // + // It used to be one more `_section` — same tinted card, same title row, same + // "Required" marker — sitting under the same stop summary, so the last screen + // of the flow looked exactly like the two the rider had just filled in. He + // read it as another thing to do rather than as the thing he was signing. + // + // It is a statement. The heading says so in the page's own voice rather than + // inside a card, the legs are separated so a combined stop reads as two + // distinct pieces of work, and the photos he took are *shown* — "Photo ✓" is + // not something anyone can check, and a black frame from a pocket-shot is the + // commonest reason a proof is thrown out weeks later, by which time the + // parcel is long gone. + Widget _reviewStep() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // The heading lives on the page, not in a card — this is the screen + // talking, not a section of it. + Padding( + padding: EdgeInsets.fromLTRB(4.w, 4.h, 4.w, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _allDone + ? Icons.fact_check_rounded + : Icons.error_outline_rounded, + size: 22.sp, + color: _allDone + ? ColorConstants.acceptGreen + : ColorConstants.warning, + ), + SizedBox(width: 9.w), + Expanded( + child: Text( + _allDone + ? 'Check before you confirm' + : 'Something is missing', + style: TextStyle( + fontSize: 19.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + SizedBox(height: 5.h), + Text( + _allDone + ? 'This is exactly what gets recorded against the stop.' + : 'Go back and finish the step above — nothing is recorded ' + 'until every part is done.', + style: TextStyle( + fontSize: 13.5.sp, + height: 1.4, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + SizedBox(height: 14.h), + + if (_kind.hasDelivery) + _reviewLeg( + title: 'Handed over', + accent: ColorConstants.deliveryAccent, + icon: Icons.local_shipping_rounded, + photo: _deliveryPhoto, + done: _deliveryDone, + facts: [ + ReviewFact( + 'Parcels', + '${_handedOver.length} of $_expectedDelivery', + icon: Icons.inventory_rounded, + ), + ReviewFact( + 'OTP', + _deliveryOtp.text.trim(), + icon: Icons.lock_outline_rounded, + mono: true, + ), + ReviewFact( + 'Received by', + _recipient.needsName + ? '${_recipientName.text.trim()} · ${_recipient.label}' + : _customerName, + icon: _recipient.icon, + wide: true, + ), + ], + ), + + if (_kind.hasDelivery && _kind.hasPickup) SizedBox(height: 12.h), + + if (_kind.hasPickup) + _reviewLeg( + title: 'Collected', + accent: ColorConstants.pickupAccent, + icon: Icons.inventory_2_rounded, + photo: _pickupPhoto, + done: _pickupDone, + facts: [ + ReviewFact( + 'Parcels', + '$_collected of $_expectedPickup', + icon: Icons.inventory_2_rounded, + ), + ReviewFact( + 'Weight', + '${_weight.text.trim()} kg', + icon: Icons.scale_rounded, + ), + ReviewFact( + 'Condition', + _condition.label, + icon: _condition.icon, + tint: _condition.accent, + ), + if (!_kind.isCombined) + ReviewFact( + 'Pickup code', + _pickupCode.text.trim(), + icon: Icons.pin_rounded, + mono: true, + ), + if (_conditionNote.text.trim().isNotEmpty) + ReviewFact( + 'Note', + _conditionNote.text.trim(), + icon: Icons.notes_rounded, + tint: ColorConstants.warning, + wide: true, + ), + ], + ), + + if (_cash > 0) ...[ + SizedBox(height: 12.h), + _notice( + 'Collect ₹${_cash.toStringAsFixed(0)} from the customer on the ' + 'next screen', + ColorConstants.acceptGreen, + ), + ], + ], + ); + } + + /// One leg of the stop, as a panel: what happened, and the photo proving it. + /// + /// The photo sits at the foot of its own leg rather than in a gallery of its + /// own, so on a combined stop there is no question which shot belongs to the + /// hand-over and which to the collection — the pairing is the whole reason + /// both are taken. + /// + /// [done] draws the leg's own verdict on the leg. On a combined stop the + /// page-level "Something is missing" says nothing about *which* of the two + /// jobs is short, and that is exactly the case where the rider has to know: + /// one tap back, or two. + Widget _reviewLeg({ + required String title, + required Color accent, + required IconData icon, + required List facts, + required bool done, + File? photo, + }) { + return Container( + width: double.infinity, + padding: EdgeInsets.all(16.r), + decoration: BoxDecoration( + color: ColorConstants.cardSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16.sp, color: accent), + SizedBox(width: 7.w), + Expanded( + child: Text( + title.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + _legVerdict(done), + ], + ), + SizedBox(height: 12.h), + _factGrid(facts, accent), + SizedBox(height: 10.h), + _proofPhoto(photo, accent), + ], + ), + ); + } + + /// The leg's own tick, or the reason there isn't one. + Widget _legVerdict(bool done) { + // The same amber the page heading uses for "Something is missing". + // ColorConstants.warning is a signal colour, not a text colour — at + // 10sp on a 12% tint it is barely there in daylight. + final color = done ? ColorConstants.acceptGreen : ColorConstants.warning; + return Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 3.h), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + done ? Icons.check_circle_rounded : Icons.error_outline_rounded, + size: 12.sp, + color: color, + ), + SizedBox(width: 4.w), + Text( + done ? 'Complete' : 'Unfinished', + style: TextStyle( + fontSize: 10.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.2, + color: color, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } + + /// The shot, as a strip you can open. + /// + /// It was a bare 130pt `cover` crop with a "Proof photo attached" caption + /// underneath, and a crop is exactly where a bad photo hides: a thumb over the + /// lens or a blown-out doorway lives at the edge of the frame that got cut. + /// Giving the photo its true 4:3 shape fixed that and cost 250pt per leg — + /// half a panel, twice over on a combined stop — on the one screen this file + /// packs facts two-to-a-row specifically to keep short. + /// + /// So the strip stays compact and the full frame is one tap away. That is + /// what the caption should have been all along: it spent a line telling the + /// rider a photo existed, which he could see, instead of letting him check + /// the thing that actually gets a proof thrown out weeks later. + /// + /// A missing photo says so rather than collapsing. The gate above should have + /// caught it, but a confirm screen that renders one leg with a photo and the + /// other with a silent gap is a confirm screen that lied about the second leg. + Widget _proofPhoto(File? photo, Color accent) { + if (photo == null) { + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 14.h), + decoration: BoxDecoration( + color: ColorConstants.warning.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Row( + children: [ + Icon( + Icons.no_photography_rounded, + size: 15.sp, + color: ColorConstants.warning, + ), + SizedBox(width: 8.w), + Expanded( + child: Text( + 'No proof photo — go back and take one', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.warning, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } + + return PressScale( + onTap: () => _openPhoto(photo), + child: ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: Stack( + children: [ + Image.file( + photo, + height: 118.h, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + height: 118.h, + color: ColorConstants.pureSurface, + alignment: Alignment.center, + child: Icon( + Icons.broken_image_rounded, + size: 22.sp, + color: ColorConstants.secondaryText, + ), + ), + ), + // The chip rides on the image instead of taking a line under it. + // Six facts, a photo and a caption did not fit above the fold, and + // the caption is the one piece that was only ever repeating what the + // picture already showed. + Positioned( + left: 8.w, + bottom: 8.h, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.62), + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.zoom_in_rounded, + size: 13.sp, + color: Colors.white, + ), + SizedBox(width: 5.w), + Text( + 'Tap to enlarge', + style: TextStyle( + fontSize: 10.sp, + fontWeight: FontWeight.w800, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + /// Full-frame view of a proof photo. Pinch-zoomable: a pickup code written on + /// a label is legible in the original and not in a 4:3 thumbnail. + void _openPhoto(File photo) { + showDialog( + context: context, + barrierColor: Colors.black.withValues(alpha: 0.92), + builder: (ctx) => Stack( + children: [ + Positioned.fill( + child: InteractiveViewer( + maxScale: 5, + child: Center(child: Image.file(photo, fit: BoxFit.contain)), + ), + ), + Positioned( + top: 8.h, + right: 8.w, + child: SafeArea( + child: IconButton( + tooltip: 'Close', + icon: Icon( + Icons.close_rounded, + size: 26.sp, + color: Colors.white, + ), + onPressed: () => Navigator.of(ctx).pop(), + ), + ), + ), + ], + ), + ); + } + + /// The leg's facts, two to a row. + /// + /// ── Why a grid and not a list ── + /// + /// These were full-width rows: an icon, a caption and a value, stacked. Every + /// one of them spent a whole line of a doorstep screen on a value that is + /// almost always four characters — "3 of 3", "4.5 kg", "482156". Six of them + /// ran past the fold, so the rider had to scroll a *confirmation* screen to + /// see what he was confirming, which is the one screen that should fit. + /// + /// Paired up, the same six facts take three lines instead of six and can be + /// read as a block rather than walked down. Values that are genuinely long — + /// a recipient's name, a note about damage — say so with [_Fact.wide] and keep + /// their own line, because wrapping those into half a column is how a grid + /// turns back into a wall of text. + /// ── Why the row is wrapped in an IntrinsicHeight ── + /// + /// It was a bare `Row(crossAxisAlignment: stretch)`. A Row's cross axis is + /// vertical, and this whole step is inside a `SingleChildScrollView`, so the + /// height it was told to stretch to was *infinity*. Every fact row threw + /// "BoxConstraints forces an infinite height" during layout, the exception + /// propagated up through the leg panel's Column, and the entire review — every + /// figure the rider was being asked to confirm — rendered as nothing at all. + /// The step only looked empty; it had actually failed. + /// + /// `IntrinsicHeight` gives the row a real height first — the taller of the two + /// tiles — which is what `stretch` was always meant to match them to. + Widget _factGrid(List facts, Color accent) { + final rows = packReviewFacts(facts); + if (rows.isEmpty) return const SizedBox.shrink(); + + return Column( + children: [ + for (var i = 0; i < rows.length; i++) + Padding( + // Gap between rows only. A trailing 8pt under the last row pushed + // the photo out of true with the panel's own padding. + padding: EdgeInsets.only(bottom: i == rows.length - 1 ? 0 : 8.h), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(child: _factTile(rows[i].first, accent)), + if (rows[i].length > 1) ...[ + SizedBox(width: 8.w), + Expanded(child: _factTile(rows[i][1], accent)), + ], + ], + ), + ), + ), + ], + ); + } + + /// One fact: a caption, and the thing itself underneath at the size the + /// rider is actually checking. + Widget _factTile(ReviewFact fact, Color accent) { + final tint = fact.tint ?? accent; + return Container( + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Icon(fact.icon, size: 13.sp, color: tint), + SizedBox(width: 6.w), + Flexible( + child: Text( + fact.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + SizedBox(height: 4.h), + Text( + fact.value, + maxLines: fact.wide ? 3 : 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + height: 1.25, + fontWeight: FontWeight.w800, + letterSpacing: fact.mono ? 1.2 : -0.3, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } + + // ── Bottom bar ───────────────────────────────────────────────────────── + // + // Slide-to-confirm only on the last step. On the leg steps it is a plain + // button: a slider on every screen turns the deliberate friction of the + // final commit into a reflex the rider stops noticing. + Widget _bottomBar() { + final hint = _blockingHint; + final onReview = _step >= _legs.length; + + return Container( + padding: EdgeInsets.fromLTRB(14.w, 10.h, 14.w, 12.h), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + border: Border( + top: BorderSide(color: ColorConstants.borderSubtle, width: 1), + ), + boxShadow: DesignConstants.shadowSm, + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (hint != null) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.info_outline_rounded, + size: 14.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + hint, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + SizedBox(height: 9.h), + ], + if (onReview) + _confirmSlider(hint == null) + 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'}', + ) + 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, + // ── Green, on every step ── + // + // This took the next leg's accent, so the forward button was + // blue on one step and green on the next inside a single stop. + // The leg accent still identifies which job is running — it is + // on the step indicator above, where identifying is its job — + // but the button that advances the work is one colour from + // Accept on Home to "Move to next stop" on the handoff. + color: ColorConstants.acceptGreen, + onPressed: _next, + ), + ], + ), + ), + ); + } + + /// The action bar when the step is not finished. + /// + /// ── Why not just a disabled [MilerButton] ── + /// + /// [MilerButton] greys out by dropping the whole control to 45% opacity, and + /// that is the wrong tool twice over here. A saturated accent at 45% on white + /// is still unmistakably a blue (or maroon) button, so the rider taps it — and + /// the label goes down with the fill, landing at roughly 1.4:1 against it, so + /// the one control on the screen becomes the least readable thing on it. + /// + /// This is a different shape instead: a neutral fill with a lock and grey + /// type at 6:1. Colourless means "not yet", the words stay readable, and the + /// moment the leg is complete the bar turns a solid accent. That change — + /// grey to colour — is the readiness signal, visible at arm's length without + /// reading anything. + Widget _blockedCta(String label) { + return Container( + height: ButtonSizes.primary, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.neutralLight, + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.lock_outline_rounded, + size: 17.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 7.w), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } + + Widget _confirmSlider(bool enabled) { + if (_confirming) { + return Container( + height: ButtonSizes.primary, + decoration: BoxDecoration( + color: ColorConstants.acceptGreen, + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + child: const Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ), + ), + ); + } + + if (!enabled) return _blockedCta('Complete every step'); + + // ── A button, not a slide ── + // + // This was a `SliderButton` reading "Slide to complete delivery", with a + // dismiss threshold of 0.85 — the rider had to drag the knob across 85% of + // the screen for it to fire. Riders tapped it. A tap on a slider does + // nothing at all: no ripple, no error, no movement, no clue that a drag was + // wanted. The whole delivery flow dead-ended on this control, one gesture + // short of finishing, and the stop stayed open on the server. + // + // Every other terminal action in this app is a tap — the confirm sheet's + // own note says "ACTION BUTTONS (tap, not swipe)" and the map screen says + // "Slides are reserved for money". This one is not money; it is the end of + // a four-step form the rider has already filled in, and the form itself is + // the deliberation. So it is a button, labelled with what it does. + return MilerButton( + label: 'Confirm ${_kind.noun.toLowerCase()}', + icon: Icons.check_circle_rounded, + color: ColorConstants.acceptGreen, + onPressed: _confirm, + ); + } + + // ── Shared pieces ────────────────────────────────────────────────────── + + /// One step of the form: a label, a line of help, and the control itself. + /// + /// ── Why this stopped being a card ── + /// + /// It was a filled, rounded, 16pt-padded panel, and there are four to six of + /// them on every leg. Stacked, they turn the page into a column of grey slabs + /// with white controls floating inside them — a *container* rank and a + /// *control* rank competing at the same weight, when only one of them is + /// something the rider touches. + /// + /// The panels were also doing nothing the page could not. A section is + /// already separated from its neighbour by the gap between them; the fill was + /// paying 32pt of padding and a whole background colour to restate it. What + /// it cost was focus: the biggest, heaviest shapes on a screen of things to + /// fill in were the boxes, not the fields. + /// + /// So the group is named and ruled off instead. The title drops to a small + /// letterspaced grey label — the quietest way to name a group, and the same + /// one the handoff screen uses — a hairline marks the change of subject, and + /// everything left with weight on the page is something the rider acts on. + /// + /// The rule is drawn *above* each section, so the first one also separates + /// the form from the stop summary heading it sits under. + Widget _section( + String title, + String? subtitle, { + required bool done, + required Widget child, + bool optional = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 22.h), + Container(height: 1, color: ColorConstants.borderSubtle), + SizedBox(height: 20.h), + Row( + 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, + ), + ), + ), + SizedBox(width: 10.w), + _sectionMarker(done: done, optional: optional), + ], + ), + 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, + ], + ); + } + + /// Whether this step is satisfied — and it means *this step*, not the screen. + /// + /// The tick is scoped to the section it sits on, which is the only reading + /// that survives a rider glancing at "Who received it? ✓" while the OTP below + /// is still empty. The bottom bar is what speaks for the screen as a whole, + /// and it names the one thing still missing. + Widget _sectionMarker({required bool done, required bool optional}) { + if (optional) { + return Text( + 'Optional', + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ); + } + if (done) { + return Icon( + Icons.check_circle_rounded, + size: 18.sp, + color: ColorConstants.acceptGreen, + ); + } + return Container( + padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 3.h), + decoration: BoxDecoration( + color: ColorConstants.primary.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Text( + 'Required', + style: TextStyle( + fontSize: 9.5.sp, + fontWeight: FontWeight.w800, + color: ColorConstants.primary, + fontFamily: FontConstants.fontFamily, + ), + ), + ); + } + + /// The recipient / condition pickers, as an even two-column grid. + /// + /// These were natural-width chips in a `Wrap`, which packed them by accident: + /// "Customer | Family | Security" on one line and "Neighbour" orphaned on the + /// next, three widths in four controls. A ragged grid of mixed-width blocks is + /// read one item at a time; an even one is read as a set, which is what a + /// four-way choice is. + /// + /// Half the row each, so two rows of two. The width comes from a + /// [LayoutBuilder] rather than `Expanded`, because these still live in a + /// `Wrap` — a chip whose own label is taller than one line has to be able to + /// grow downward without dragging its neighbour with it. + /// + /// [count] is passed so an odd number can be handled: three conditions in a + /// two-up grid leaves the third at half width on a line of its own, which + /// looks like the grid ran out rather than like a row. The last one takes the + /// full width instead, and the block reads as two deliberate rows. + Widget _choiceGrid({ + required int count, + required List Function(double width, double lastWidth) chips, + }) { + return LayoutBuilder( + builder: (context, constraints) { + const gap = 9.0; + final full = constraints.maxWidth; + final half = (full - gap) / 2; + return Wrap( + spacing: gap, + runSpacing: gap, + children: chips(half, count.isOdd ? full : half), + ); + }, + ); + } + + Widget _choiceChip({ + required String label, + required IconData icon, + required bool selected, + required Color accent, + required VoidCallback onTap, + double? width, + }) { + return SizedBox( + width: width, + child: PressScale( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h), + decoration: BoxDecoration( + color: selected ? accent : ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + // An edge at rest. On the old grey panel a white chip separated + // itself by fill alone; on a white page it has nothing to separate + // it from, and an unselected option that reads as plain text is an + // option the rider does not know he can press. + border: Border.all( + color: selected ? accent : ColorConstants.borderStrong, + width: 1.4, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 16.sp, + color: selected ? Colors.white : ColorConstants.secondaryText, + ), + SizedBox(width: 7.w), + // Flexible, not a bare Text. A [Wrap] hands every chip the full page + // width to measure against, so a chip is only ever as wide as its + // own label — and at a large system font "Sealed & intact" is wider + // 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( + label, + maxLines: 2, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: selected ? Colors.white : ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _field({ + required TextEditingController controller, + required String hint, + required IconData icon, + int maxLines = 1, + TextInputType? keyboard, + List? formatters, + TextCapitalization capitalization = TextCapitalization.none, + }) { + return TextField( + controller: controller, + maxLines: maxLines, + keyboardType: keyboard, + inputFormatters: formatters, + textCapitalization: capitalization, + onChanged: (_) => setState(() {}), + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + prefixIcon: Icon( + icon, + size: 18.sp, + color: ColorConstants.secondaryText, + ), + filled: true, + fillColor: ColorConstants.pureSurface, + contentPadding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 13.h), + // Outlined at rest, with the accent spent on the field the cursor is + // in. This was borderless, which worked while the fill sat on a grey + // panel and said "type here" by contrast alone — on a white page a + // white borderless field is not a field. + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide(color: ColorConstants.borderStrong, width: 1.4), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide(color: ColorConstants.borderStrong, width: 1.4), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide(color: ColorConstants.primary, width: 1.6), + ), + ), + ); + } + + Widget _photoBox({ + required File? file, + required Color accent, + required VoidCallback onTap, + required VoidCallback onClear, + }) { + if (file != null) { + return Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: Image.file( + file, + height: 160.h, + width: double.infinity, + fit: BoxFit.cover, + ), + ), + SizedBox(height: 9.h), + Row( + children: [ + Expanded( + child: MilerButton( + label: 'Retake', + icon: Icons.refresh_rounded, + variant: MilerButtonVariant.outlined, + color: ColorConstants.slateText, + height: ButtonSizes.compact, + onPressed: onTap, + ), + ), + SizedBox(width: 9.w), + Expanded( + child: MilerButton( + label: 'Remove', + icon: Icons.delete_outline_rounded, + variant: MilerButtonVariant.outlined, + color: ColorConstants.errorRed, + height: ButtonSizes.compact, + onPressed: onClear, + ), + ), + ], + ), + ], + ); + } + + return PressScale( + onTap: onTap, + child: Container( + // Padded, not a fixed height. `.h` scales with screen HEIGHT and `.sp` + // with width, so a fixed-height box around sp-sized content overflows + // on a short, wide screen — or on any phone with the system font + // scaled up. Letting it size to its content cannot. + padding: EdgeInsets.symmetric(vertical: 30.h), + width: double.infinity, + 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, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.photo_camera_rounded, size: 27.sp, color: accent), + SizedBox(height: 7.h), + Text( + 'Take photo', + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w800, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ); + } + + Widget _notice(String text, Color accent) { + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 11.h), + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Row( + children: [ + Icon(Icons.info_rounded, size: 16.sp, color: accent), + SizedBox(width: 8.w), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 12.sp, + height: 1.35, + fontWeight: FontWeight.w700, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } +} + +/// Packs review facts into rows of one or two. +/// +/// Top-level and pure so the rule can be asserted directly: the review step +/// sits behind a camera gate no widget test can pass, and "does the grid pair +/// things correctly" is the part worth holding. +/// +/// A wide fact takes the row it starts on; narrow ones pair off around it. +/// Order is meaning here — parcels before condition, weight before the note — +/// so nothing is re-sorted to make the packing tidier. +List> packReviewFacts(List facts) { + final live = facts.where((f) => f.value.trim().isNotEmpty).toList(); + final rows = >[]; + + var i = 0; + while (i < live.length) { + final first = live[i]; + final canPair = !first.wide && i + 1 < live.length && !live[i + 1].wide; + rows.add(canPair ? [first, live[i + 1]] : [first]); + i += canPair ? 2 : 1; + } + return rows; +} + +/// One thing the review screen is asserting about the stop. +/// +/// A value plus how to draw it, rather than a pre-built widget, so [_factGrid] +/// can decide the packing — which facts pair off and which take a row — without +/// the caller having to know the layout. +class ReviewFact { + final String label; + final String value; + final IconData icon; + + /// Takes its own row. For values that are sentences rather than figures. + final bool wide; + + /// Codes read digit by digit, so they are tracked out. + final bool mono; + + /// Overrides the leg's accent — a damaged condition is amber wherever it + /// appears, not the pickup maroon. + final Color? tint; + + const ReviewFact( + this.label, + this.value, { + required this.icon, + this.wide = false, + this.mono = false, + this.tint, + }); +} + +/// Boxed digit entry, the shape every Indian delivery app uses for an OTP. +/// +/// One real [TextField] sits invisibly behind the boxes and owns focus and the +/// keyboard; the boxes just draw its characters. Building it out of six real +/// fields means six focus nodes and hand-rolled backspace behaviour, which is +/// where these widgets usually break. +class _CodeBoxes extends StatefulWidget { + final TextEditingController controller; + final int length; + final Color accent; + final VoidCallback onChanged; + + const _CodeBoxes({ + required this.controller, + required this.length, + required this.accent, + required this.onChanged, + }); + + @override + State<_CodeBoxes> createState() => _CodeBoxesState(); +} + +class _CodeBoxesState extends State<_CodeBoxes> { + final FocusNode _focus = FocusNode(); + + @override + void initState() { + super.initState(); + _focus.addListener(() => setState(() {})); + } + + @override + void dispose() { + _focus.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final text = widget.controller.text; + + return Stack( + children: [ + // The invisible input. Kept in the tree (not Offstage) so the platform + // keyboard and autofill still attach to it. + SizedBox( + height: 48.h, + child: Opacity( + opacity: 0, + child: TextField( + controller: widget.controller, + focusNode: _focus, + keyboardType: TextInputType.number, + maxLength: widget.length, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + showCursor: false, + enableInteractiveSelection: false, + // maxLength draws a "0/6" counter by default, which would make + // this invisible field taller than the boxes drawn over it and + // overflow the row. + decoration: const InputDecoration( + counterText: '', + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + ), + onChanged: (_) { + setState(() {}); + widget.onChanged(); + }, + ), + ), + ), + Positioned.fill( + child: GestureDetector( + onTap: () => _focus.requestFocus(), + behavior: HitTestBehavior.opaque, + child: Row( + children: List.generate(widget.length, (i) { + final filled = i < text.length; + final active = _focus.hasFocus && i == text.length; + return Expanded( + child: Container( + height: 48.h, + margin: EdgeInsets.symmetric( + horizontal: MilerCodeBox.gap(context).w / 2, + ), + alignment: Alignment.center, + // The same spec the login code uses — see [MilerCodeBox]. + // The two screens keep different input models (this one + // draws over a single hidden field so paste works; auth + // uses one controller per box so SMS autofill works) but + // the rider meets one control. + decoration: MilerCodeBox.decoration( + focused: active, + filled: filled, + hasError: false, + accent: widget.accent, + ), + child: Text( + filled ? text[i] : '', + style: MilerCodeBox.digit(widget.accent), + ), + ), + ); + }), + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/Dashboard/profile/Profilepage.dart b/lib/views/Dashboard/profile/Profilepage.dart new file mode 100644 index 0000000..f9eb5d6 --- /dev/null +++ b/lib/views/Dashboard/profile/Profilepage.dart @@ -0,0 +1,625 @@ +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/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'; +import 'package:miler/views/Dashboard/profile/informations/rider_rewards_page.dart'; +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/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/widgets/miler_app_bar.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/widget/Bottom_page.dart'; +import 'package:miler/views/onboardscreens/Sign_in.dart'; +import 'package:miler/controllers/profile_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/controllers/rewards_controller.dart'; +import 'package:miler/controllers/summary_controller.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.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.isRegistered() + ? Get.find() + : Get.put(RewardsController()); + + final SummaryController _summaryController = + Get.isRegistered() + ? Get.find() + : Get.put(SummaryController()); + + String _name = ''; + String _email = ''; + String _contact = ''; + String _appVersion = ''; + + /// Name and phone come off disk. Until they land, the card used to state + /// "Rider Name / Not added" as if that were the answer — so the rider's first + /// glimpse of his own account said his number was missing. + bool _prefsLoaded = false; + + @override + void initState() { + super.initState(); + _loadProfilePrefs(); + _loadAppVersion(); + ever(_profileController.userName, (_) => _assignFromController()); + ever(_profileController.userEmail, (_) => _assignFromController()); + ever(_profileController.userContact, (_) => _assignFromController()); + _profileController.loadFromPrefs(); + } + + Future _loadAppVersion() async { + try { + final info = await PackageInfo.fromPlatform(); + if (!mounted) return; + setState( + () => _appVersion = 'v${info.version} (Build ${info.buildNumber})', + ); + } catch (_) {} + } + + Future _loadProfilePrefs() async { + final prefs = await SharedPreferences.getInstance(); + 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'; + _prefsLoaded = true; + }); + final userId = prefs.getInt('userid') ?? 0; + if (userId > 0) { + _rewardsController.fetchBonusSummary(userId); + _summaryController.fetchSummaryStats(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 Scaffold( + // ── The same white as the other tabs ── + // + // This was [ColorConstants.surface] — #FCF9F8, a warm off-white — while + // Home and Activity are on [ColorConstants.pureSurface]. On Bookings the + // difference never shows, because cards cover the page; Account is bare + // 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, + // 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', + ), + ), + // ── Pull to refresh ── + // + // A pull here used to drag the page off the bottom of the screen and + // spring back having done nothing: `BouncingScrollPhysics` forces the iOS + // rubber-band on every platform, and rubber-band overscroll is unbounded + // unless a RefreshIndicator catches the gesture. The gesture responded, + // 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 ── + // + // 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()), + ), + ], + ), + 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, + ), + ), + ), + SizedBox(height: 20.h), + ], + ), + ); + } + + /// Re-reads everything the page shows. Deliberately awaits, so the spinner + /// stays down until the numbers are actually new. + Future _refresh() async { + await _profileController.loadFromPrefs(); + await _loadProfilePrefs(); + await _loadAppVersion(); + } + + // ─── Section label ─────────────────────────────────────────────────────────── + + // ─── Header icon button ────────────────────────────────────────────────────── + Widget _circleIconButton( + IconData icon, + VoidCallback onTap, { + required String semanticLabel, + }) { + return PressScale( + onTap: onTap, + semanticLabel: semanticLabel, + 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: Icon(icon, color: ColorConstants.onSurface, size: 22.sp), + ), + ); + } + + // ─── Profile card ──────────────────────────────────────────────────────────── + Widget _profileCard() { + return PressScale( + onTap: () => openSheet(context, const Profile()), + child: Container( + padding: EdgeInsets.all(16.w), + decoration: SettingsCard.decoration(), + 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), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + ], + ), + ), + ] else ...[ + Text( + _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, + ), + ), + ], + 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, + ), + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right_rounded, + color: ColorConstants.onSurfaceVariant, + size: 22.sp, + ), + ], + ), + ), + ); + } + + // ─── 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()), + ), + ], + ), + ); + } + + 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( + 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, + ), + ), + ], + ), + ), + ), + ); + } + + /// Formats an integer with thousands separators (e.g. 12450 -> "12,450"). + String _formatCount(int value) { + final s = value.toString(); + final buf = StringBuffer(); + for (int i = 0; i < s.length; i++) { + if (i > 0 && (s.length - i) % 3 == 0) buf.write(','); + buf.write(s[i]); + } + return buf.toString(); + } + + /// 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. + static const String _emergencyNumber = '112'; + + /// Fails loudly. `canLaunchUrl` returning false used to end the story: the + /// rider taps SOS in an emergency and the app does nothing at all. + Future _callEmergency() async { + final Uri url = Uri(scheme: 'tel', path: _emergencyNumber); + bool launched = false; + try { + if (await canLaunchUrl(url)) { + launched = await launchUrl(url); + } + } catch (_) { + launched = false; + } + if (!launched && mounted) { + AppFeedback.error( + context, + 'Could not place the call. Dial $_emergencyNumber from your phone.', + duration: const Duration(seconds: 8), + ); + } + } + + // ─── SOS banner ───────────────────────────────────────────────────────────── + + 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, + ), + ); + } +} + +// ─── Logout dialog ──────────────────────────────────────────────────────────── + +void _showLogoutDialog(BuildContext context) { + showAppDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + backgroundColor: ColorConstants.surfaceContainerLowest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + title: Text( + "Logout", + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.onSurface, + ), + ), + content: Text( + "Are you sure you want to logout?", + style: TextStyle( + fontSize: 14.sp, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + actionsPadding: EdgeInsets.fromLTRB(20.w, 4.h, 20.w, 18.h), + actions: [ + Row( + children: [ + Expanded( + child: MilerButton( + label: "Cancel", + onPressed: () => Navigator.pop(context), + variant: MilerButtonVariant.outlined, + color: ColorConstants.onSurfaceVariant, + height: ButtonSizes.secondary, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: MilerButton( + label: "Logout", + onPressed: () async { + final prefs = await SharedPreferences.getInstance(); + 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 new file mode 100644 index 0000000..7128cfd --- /dev/null +++ b/lib/views/Dashboard/profile/informations/faq.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.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/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'; + +/// ───────────────────────────────────────────────────────────────────────── +/// FAQ +/// +/// ── What was here before ── +/// +/// A page titled **"Help Center"** — which is a different page in this app, +/// one row above it in the Account list — opening on a full-bleed red hero, a +/// search field that searched nothing, and a grid of four category cards +/// ("Rider Guidelines", "Safety Tips", "Payment Issues", "Technical Support") +/// that were not tappable and led nowhere. Under all of that sat three actual +/// answers, and under those a second SOS card duplicating the one on Account. +/// +/// Three quarters of the screen was furniture. Worse, the two most prominent +/// elements on it — the search box and the category grid — were inert. A +/// control that does not respond teaches the rider that the app is broken, and +/// he stops trying the ones that do work. Absent beats fake. +/// +/// ── What it is now ── +/// +/// The answers, first, in the section's card. A live search that actually +/// filters them, shown only because it now does something. And one route out +/// when the answer is not here: message support. The SOS lives on Account, +/// where a rider in trouble already knows to find it, and is not repeated. +/// ───────────────────────────────────────────────────────────────────────── +class FaqPage extends StatefulWidget { + const FaqPage({super.key}); + + @override + State createState() => _FaqPageState(); +} + +class _FaqPageState extends State { + final TextEditingController _search = TextEditingController(); + String _query = ''; + + /// Written for this app, not for a generic delivery app: slot-based trips + /// assigned by a hub, a round trip back to the hub, skip-don't-reorder, and + /// cash the rider is carrying on someone else's behalf. + static const List<_Faq> _faqs = [ + _Faq( + 'What is a trip, and why can I only work one stop at a time?', + 'Your hub assigns a whole slot as one trip: pick up from every customer ' + 'on it, then return to the hub. The stops are ordered for you, so the ' + 'app opens them one at a time — finish the active stop before the ' + 'next one unlocks.', + ), + _Faq( + 'A customer is not answering. Can I move them to the end?', + 'No — a trip keeps its order. Skip the stop with a reason instead. It ' + 'comes out of your queue, your hub sees why, and the rest of the trip ' + 'carries on unchanged.', + ), + _Faq( + 'I am running late because of traffic. What should I do?', + 'Keep the app open so your location keeps reporting — the hub can see ' + 'the delay without you calling. If a customer needs to know, use the ' + 'call button on the stop.', + ), + _Faq( + 'The app says I am too far from the pickup.', + 'Arrival is confirmed by GPS, within 500 m of the pickup point. If you ' + 'are at the door and it still refuses, wait a few seconds for the fix ' + 'to settle, and check that location permission is set to "Allow all ' + 'the time".', + ), + _Faq( + 'I collected cash. Whose money is it?', + 'It is the customer\'s payment to the business you are picking up from, ' + 'held by you until you hand it in at the hub. Record it in the app at ' + 'the stop so the amount you carry and the amount expected match.', + ), + _Faq( + 'When do I get paid, and what are points?', + 'Earnings are settled by your hub on its regular cycle — Earnings shows ' + 'what has been counted so far. Points are separate: they are earned ' + 'on completed trips and shown under Rewards.', + ), + _Faq( + 'I did not hear a new booking arrive.', + '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 ' + 'Address for your address. Your mobile number is fixed to the one ' + 'your hub registered — contact support to change it.', + ), + ]; + + List<_Faq> get _results { + final q = _query.trim().toLowerCase(); + if (q.isEmpty) return _faqs; + return _faqs + .where( + (f) => + f.question.toLowerCase().contains(q) || + f.answer.toLowerCase().contains(q), + ) + .toList(); + } + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final results = _results; + + return Scaffold( + backgroundColor: ColorConstants.surface, + appBar: milerPageBar('FAQ', onBack: () => Get.back()), + body: SettingsBody( + children: [ + _searchField(), + SizedBox(height: 18.h), + if (results.isEmpty) + _noResults() + else + SettingsCard( + child: Column( + children: [ + for (int i = 0; i < results.length; i++) ...[ + _FaqTile(faq: results[i]), + if (i != results.length - 1) + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Divider( + height: 1, + thickness: 1, + color: ColorConstants.dividerColor.withValues( + alpha: 0.6, + ), + ), + ), + ], + ], + ), + ), + SizedBox(height: 22.h), + _stillStuck(), + SizedBox(height: 12.h), + ], + ), + ); + } + + Widget _searchField() { + return Container( + decoration: BoxDecoration( + color: ColorConstants.surfaceContainerLowest, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: ColorConstants.dividerColor), + ), + child: TextField( + controller: _search, + onChanged: (v) => setState(() => _query = v), + style: TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + decoration: InputDecoration( + hintText: 'Search answers', + hintStyle: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w400, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + prefixIcon: Icon( + Icons.search_rounded, + size: 20.sp, + color: ColorConstants.onSurfaceVariant, + ), + suffixIcon: _query.isEmpty + ? null + : IconButton( + tooltip: 'Close', + icon: Icon( + Icons.close_rounded, + size: 18.sp, + color: ColorConstants.onSurfaceVariant, + ), + onPressed: () { + _search.clear(); + setState(() => _query = ''); + }, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: EdgeInsets.symmetric( + horizontal: 14.w, + vertical: 14.h, + ), + ), + ), + ); + } + + Widget _noResults() { + return SettingsCard( + padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 28.h), + child: Column( + children: [ + Icon( + Icons.search_off_rounded, + size: 34.sp, + color: ColorConstants.onSurfaceVariant, + ), + SizedBox(height: 12.h), + Text( + 'No answer matches “${_query.trim()}”', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 6.h), + Text( + 'Send us the question and we will answer it directly.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12.5.sp, + height: 1.45, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } + + /// The one way out of this page. A rider who did not find his answer needs a + /// person, not another list. + Widget _stillStuck() { + return SettingsCard( + padding: EdgeInsets.all(16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Still stuck?', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 4.h), + Text( + 'Message the support team — we usually reply within a day.', + style: TextStyle( + fontSize: 13.sp, + height: 1.45, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 14.h), + MilerButton( + label: 'Contact support', + icon: Icons.mail_outline_rounded, + variant: MilerButtonVariant.tonal, + height: ButtonSizes.secondary, + onPressed: () => openSheet(context, const HelpCenterMessage()), + ), + ], + ), + ); + } +} + +/// One question, expanding to its answer. +/// +/// Hand-rolled rather than an `ExpansionTile` so it matches [SettingsRow]'s +/// metrics — the tile brought Material's own 56dp list geometry, its own +/// chevron colour and a `Theme` override to suppress its dividers, none of +/// which line up with the rest of the section. +class _FaqTile extends StatefulWidget { + final _Faq faq; + const _FaqTile({required this.faq}); + + @override + State<_FaqTile> createState() => _FaqTileState(); +} + +class _FaqTileState extends State<_FaqTile> { + bool _open = false; + + @override + Widget build(BuildContext context) { + return PressScale( + onTap: () => setState(() => _open = !_open), + scale: 0.99, + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 15.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + widget.faq.question, + style: TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w600, + height: 1.35, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 12.w), + AnimatedRotation( + turns: _open ? 0.5 : 0, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + child: Icon( + Icons.expand_more_rounded, + size: 22.sp, + color: _open + ? ColorConstants.primary + : ColorConstants.onSurfaceVariant, + ), + ), + ], + ), + // AnimatedSize rather than a hard show/hide: the card grows into + // the answer, which is the same easing everything else in the app + // moves on. + AnimatedSize( + duration: const Duration(milliseconds: 240), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: _open + ? Padding( + padding: EdgeInsets.only(top: 10.h, right: 24.w), + child: Text( + widget.faq.answer, + style: TextStyle( + fontSize: 13.sp, + height: 1.55, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ) + : const SizedBox(width: double.infinity), + ), + ], + ), + ), + ); + } +} + +class _Faq { + final String question; + final String answer; + const _Faq(this.question, this.answer); +} diff --git a/lib/views/Dashboard/profile/informations/help_center.dart b/lib/views/Dashboard/profile/informations/help_center.dart new file mode 100644 index 0000000..e6aa1f5 --- /dev/null +++ b/lib/views/Dashboard/profile/informations/help_center.dart @@ -0,0 +1,340 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:miler/data/miler_api.dart'; +import 'package:miler/views/Dashboard/profile/informations/faq.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/page_transitions.dart'; +import 'package:miler/views/helpers/widgets/settings_ui.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// HELP & SUPPORT +/// +/// Contact-first. A rider opening this screen has already failed to solve +/// something on his own, usually mid-shift with a customer waiting, so the +/// fastest route to a human is the top of the page and the FAQ is the +/// alternative underneath — not the other way round. +/// +/// The page kept its own copies of the card and row builders; they are now the +/// shared [SettingsCard] / [SettingsRow], so it cannot drift from Account +/// again. +/// ───────────────────────────────────────────────────────────────────────── +class HelpCenter extends StatelessWidget { + const HelpCenter({super.key}); + + /// The hub's support line. One constant, used by the call row. + static const String _supportPhone = '+918925545000'; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ColorConstants.surface, + appBar: milerPageBar('Help & Support', onBack: () => Get.back()), + body: SettingsBody( + children: [ + _intro(), + SizedBox(height: 22.h), + const SettingsLabel('Talk to us'), + SettingsGroup( + rows: [ + SettingsRow( + icon: Icons.mail_outline_rounded, + title: 'Send a message', + subtitle: 'We usually reply within a day', + onTap: () => openSheet(context, const HelpCenterMessage()), + ), + SettingsRow( + icon: Icons.call_outlined, + title: 'Call the hub', + subtitle: 'For anything that cannot wait', + onTap: () => _dial(context, _supportPhone), + ), + ], + ), + SizedBox(height: 22.h), + const SettingsLabel('Answer it yourself'), + SettingsGroup( + rows: [ + SettingsRow( + icon: Icons.quiz_outlined, + title: 'Browse the FAQ', + subtitle: 'Trips, skips, cash, alerts and payments', + onTap: () => openSheet(context, const FaqPage()), + ), + ], + ), + SizedBox(height: 22.h), + _emergency(context), + SizedBox(height: 12.h), + ], + ), + ); + } + + Widget _intro() { + return SettingsCard( + padding: EdgeInsets.all(18.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'How can we help?', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.4, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 6.h), + Text( + 'Tell us what happened — the stop, the customer, what the app did — ' + 'and we will pick it up from your side of the trip.', + style: TextStyle( + fontSize: 13.5.sp, + height: 1.5, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } + + /// 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, + ), + ); + } + + /// Says so when the dialer will not open, rather than absorbing the tap. + static Future _dial(BuildContext context, String number) async { + final Uri url = Uri(scheme: 'tel', path: number); + bool launched = false; + try { + if (await canLaunchUrl(url)) launched = await launchUrl(url); + } catch (_) { + launched = false; + } + if (!launched && context.mounted) { + AppFeedback.error(context, 'Could not place the call. Dial $number.'); + } + } +} + +/// ───────────────────────────────────────────────────────────────────────── +/// CONTACT SUPPORT +/// +/// Opens as a task sheet from the bottom, because it is something the rider +/// finishes or abandons rather than browses. +/// ───────────────────────────────────────────────────────────────────────── +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(); + bool _sending = false; + + @override + void dispose() { + _subjectController.dispose(); + _messageController.dispose(); + super.dispose(); + } + + /// ── This used to be a 400ms lie ── + /// + /// The old body was `await Future.delayed(400ms)` followed by "Our team will + /// get back to you." Nothing was sent anywhere. A rider with a genuine + /// problem — a customer who would not pay, a parcel that did not match — + /// typed it out, was told it had been received, and it had not been. + /// + /// It now posts a real ticket, and says so only when the server agrees. + Future _send() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + FocusScope.of(context).unfocus(); + setState(() => _sending = true); + + final res = await MilerApi.createSupportTicket( + subject: _subjectController.text.trim(), + description: _messageController.text.trim(), + ); + + if (!mounted) return; + setState(() => _sending = false); + + if (!res.ok) { + // Kept on screen with the text intact — a rider who has just typed out a + // problem should not have to type it again. + AppFeedback.error( + context, + res.message.isEmpty + ? "Couldn't send that — check your signal and try again" + : res.message, + ); + return; + } + + Get.back(); + AppFeedback.successGlobal('Our team will get back to you.'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ColorConstants.surface, + appBar: milerPageBar('Contact Support', onBack: () => Get.back()), + body: SafeArea( + bottom: false, + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 24.h), + child: Form( + key: _formKey, + child: Reveal( + child: SettingsCard( + padding: EdgeInsets.all(16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'What has happened?', + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w800, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 6.h), + Text( + 'Include the order ID if you have it — it gets your ' + 'message to the right person faster.', + style: TextStyle( + fontSize: 13.sp, + height: 1.45, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 18.h), + _label('Subject'), + SizedBox(height: 8.h), + _field( + controller: _subjectController, + hint: 'What is this about?', + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Please enter a subject' + : null, + ), + SizedBox(height: 16.h), + _label('Your message'), + SizedBox(height: 8.h), + _field( + controller: _messageController, + hint: 'Describe your issue…', + minLines: 5, + maxLines: 8, + validator: (v) => (v == null || v.trim().isEmpty) + ? 'Please enter your message' + : null, + ), + ], + ), + ), + ), + ), + ), + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 12.h), + child: PrimaryButton( + label: 'Send message', + loading: _sending, + onPressed: _send, + ), + ), + ), + ); + } + + Widget _label(String text) => Text( + text, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ); + + Widget _field({ + required TextEditingController controller, + required String hint, + int minLines = 1, + int maxLines = 1, + String? Function(String?)? validator, + }) { + return TextFormField( + controller: controller, + minLines: minLines, + maxLines: maxLines, + validator: validator, + style: TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + decoration: InputDecoration( + hintText: hint, + hintStyle: TextStyle( + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + fontWeight: FontWeight.w400, + fontSize: 14.sp, + ), + filled: true, + // A white field on a white card has no edge but its border. + // The neutral fill is what separates them. + fillColor: ColorConstants.neutralLight, + alignLabelWithHint: true, + contentPadding: EdgeInsets.symmetric(vertical: 14.h, horizontal: 14.w), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide(color: ColorConstants.dividerColor), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide(color: ColorConstants.dividerColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + borderSide: BorderSide(color: ColorConstants.primary, width: 1.5), + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/profile/informations/notifications_page.dart b/lib/views/Dashboard/profile/informations/notifications_page.dart new file mode 100644 index 0000000..f1e6a4b --- /dev/null +++ b/lib/views/Dashboard/profile/informations/notifications_page.dart @@ -0,0 +1,364 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/data/miler_api.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/settings_ui.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// NOTIFICATIONS +/// +/// Reached from the bell on Account, and from the Account list. It is the +/// screen most likely to be opened straight after Account, which is why the +/// mismatch mattered: it was drawn on `grey.shade50` with `grey.shade100` +/// cards, `Colors.black87` titles and `grey.shade600` body — none of them the +/// app's tokens — so tapping the bell moved the rider to what looked like a +/// different product. +/// +/// Two other things were wrong, and both were worse than the styling: +/// +/// 1. **It invented notifications.** When the log was empty the page filled +/// it with three fabricated entries — "Pickup at 123 Main St. Estimated +/// payout $12.40", a "$342.50" weekly payout, a maintenance window — shown +/// to the rider as if they were real, in dollars, in an app that pays in +/// rupees. A rider who reads that his payout has been transferred and finds +/// nothing in his bank has been lied to by his own app. Placeholder data +/// does not belong on a screen a real person reads; an empty inbox is a +/// perfectly good thing to say, and now it says it. +/// +/// 2. **It had filters that did not exist.** `_selectedFilter`, a four-entry +/// `_filters` list and a whole `_buildFilterChips` widget were carried in +/// the state and referenced by the list — but the chips were never built, +/// so the filter could never be anything but "All". Dead controls are worse +/// than missing ones; this is now genuinely one list. +/// ───────────────────────────────────────────────────────────────────────── +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(); + } + + /// Two sources, deliberately. + /// + /// `GET /miler/notifications` is synthesized fresh from the rider's + /// `BookingAssignment` rows on every call — so it is really "what the hub has + /// assigned you", and it is authoritative for that. The local log is the + /// push messages this device actually received, which the server list knows + /// nothing about. Neither is a superset of the other, so both are shown. + /// + /// **Read state is not modelled.** `PATCH /notifications/:id/read` returns + /// success without persisting anything — there is no table behind it — and + /// the server's `id` is just an array index, valid only until the next fetch. + /// So nothing here marks anything as read, and nothing should be built on top + /// of it until that table exists. + Future _load() async { + final merged = >[]; + + // Server first: assignments are the ones that matter operationally. + final res = await MilerApi.notifications(); + if (res.ok) { + for (final row in res.list.whereType()) { + merged.add({ + 'title': (row['title'] ?? 'New assignment').toString(), + 'body': (row['message'] ?? row['body'] ?? '').toString(), + 'time': (row['createdat'] ?? row['time'] ?? '').toString(), + 'type': (row['type'] ?? 'assignment').toString(), + }); + } + } + + // Then whatever actually arrived on this handset. + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString('notifications_log'); + if (raw != null && raw.isNotEmpty) { + try { + final list = jsonDecode(raw) as List; + merged.addAll( + list.map((e) => (e as Map).map((k, v) => MapEntry(k.toString(), v))), + ); + } catch (_) {} + } + + if (!mounted) return; + setState(() { + _items = merged; + _loading = false; + }); + } + + Future _clearAll() async { + final ok = await showAppDialog( + context: context, + builder: (ctx) => AlertDialog( + backgroundColor: ColorConstants.surfaceContainerLowest, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + title: Text( + 'Clear notifications?', + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + content: Text( + 'This removes them from this phone. It does not undo anything they ' + 'told you about.', + style: TextStyle( + fontSize: 14.sp, + height: 1.45, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text( + 'Cancel', + style: TextStyle( + color: ColorConstants.onSurfaceVariant, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text( + 'Clear', + style: TextStyle( + color: ColorConstants.primary, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + if (ok != true) return; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('notifications_log'); + if (!mounted) return; + setState(() => _items = const []); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ColorConstants.surface, + appBar: milerPageBar( + 'Notifications', + onBack: () => Get.back(), + actions: [ + if (!_loading && _items.isNotEmpty) + TextButton( + onPressed: _clearAll, + child: Text( + 'Clear', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.primary, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + body: SmoothSwap( + child: _loading + ? const SkeletonList(count: 5) + : _items.isEmpty + ? const MilerEmptyState( + icon: Icons.notifications_none_rounded, + title: 'No notifications yet', + message: + 'Updates about your bookings, your shift and your ' + 'earnings arrive here.', + ) + : RefreshIndicator( + onRefresh: _load, + color: ColorConstants.primary, + backgroundColor: ColorConstants.surfaceContainerLowest, + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 28.h), + itemCount: _items.length, + separatorBuilder: (_, _) => SizedBox(height: 12.h), + itemBuilder: (context, index) { + final it = _items[index]; + return Reveal( + key: ValueKey('notif_${it['id'] ?? index}'), + delay: staggerDelay(index), + child: _NotificationCard(item: it), + ); + }, + ), + ), + ), + ); + } +} + +/// One notification, in the section's card. +/// +/// 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 { + final Map item; + + const _NotificationCard({required this.item}); + + ({IconData icon, Color accent}) _kind() { + final type = (item['type'] ?? '').toString().toLowerCase(); + final title = (item['title'] ?? '').toString().toLowerCase(); + + if (type == 'earning' || + title.contains('payment') || + title.contains('payout') || + title.contains('earning')) { + return ( + icon: Icons.account_balance_wallet_outlined, + 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: Icons.info_outline_rounded, accent: ColorConstants.tertiary); + } + + @override + Widget build(BuildContext context) { + final kind = _kind(); + final title = (item['title'] ?? 'Miler').toString(); + final body = (item['body'] ?? '').toString(); + final time = (item['time'] ?? 'Just now').toString(); + final imageUrl = (item['imageUrl'] ?? '').toString(); + final imagePath = (item['imagePath'] ?? '').toString(); + + return SettingsCard( + padding: EdgeInsets.all(16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 42.w, + height: 42.w, + decoration: BoxDecoration( + color: kind.accent.withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Icon(kind.icon, color: kind.accent, size: 21.sp), + ), + SizedBox(width: 14.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + height: 1.3, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 10.w), + Text( + time, + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + if (body.trim().isNotEmpty) ...[ + SizedBox(height: 5.h), + Text( + body, + style: TextStyle( + fontSize: 13.sp, + height: 1.45, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ], + ), + ), + ], + ), + if (imagePath.isNotEmpty || imageUrl.isNotEmpty) ...[ + SizedBox(height: 12.h), + ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + child: imagePath.isNotEmpty + ? Image.file( + File(imagePath), + height: 140.h, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ) + : Image.network( + imageUrl, + height: 140.h, + width: double.infinity, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/views/Dashboard/profile/informations/order_alert_sound.dart b/lib/views/Dashboard/profile/informations/order_alert_sound.dart new file mode 100644 index 0000000..defa1eb --- /dev/null +++ b/lib/views/Dashboard/profile/informations/order_alert_sound.dart @@ -0,0 +1,242 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.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/settings_ui.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// ALERT SOUND +/// +/// The sound a new booking makes. For a rider on a bike this is not a cosmetic +/// setting — it is the difference between hearing work arrive and missing it — +/// so the screen is built around *hearing* the options, not reading a list of +/// filenames. +/// +/// What it replaced: a raw Material `ListTile` list with a leading `Radio`, a +/// grey `dividerColor` slab as a header, `const EdgeInsets` and unscaled font +/// sizes, and a snackbar on every tap. It shared no visual element with the +/// page that opened it. Now: one card, one row per sound, a play button that is +/// obviously a play button, and the current choice marked by a filled brand +/// check rather than a system radio the app uses nowhere else. +/// +/// Selection saves immediately — there is no Save button, because there is +/// nothing to confirm and a rider on a shift should not have to. +/// ───────────────────────────────────────────────────────────────────────── +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; + String? _playing; + bool _loading = true; + + static const List<_SoundOption> _options = [ + _SoundOption(label: 'Alert 1', 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(); + _player.onPlayerComplete.listen((_) { + if (mounted) setState(() => _playing = null); + }); + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + Future _loadSelection() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_prefsKey); + if (!mounted) return; + setState(() { + _selected = (saved != null && saved.isNotEmpty) ? saved : _defaultSound; + _loading = false; + }); + } + + /// Picking a sound plays it. Choosing an alert you cannot hear is not a + /// choice, and the old screen made preview a separate, easily-missed button. + Future _select(_SoundOption opt) async { + setState(() => _selected = opt.assetPath); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, opt.assetPath); + await _preview(opt); + } + + Future _preview(_SoundOption opt) async { + try { + await _player.stop(); + if (!mounted) return; + setState(() => _playing = opt.assetPath); + // AssetSource resolves relative to the assets/ root. + await _player.play( + AssetSource(opt.assetPath.replaceFirst('assets/', '')), + ); + } catch (_) { + if (!mounted) return; + setState(() => _playing = null); + } + } + + String get _currentLabel => _options + .firstWhere((o) => o.assetPath == _selected, orElse: () => _options.first) + .label; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ColorConstants.surface, + appBar: milerPageBar('Alert Sound', onBack: () => Get.back()), + body: SmoothSwap( + child: _loading + ? const Center(child: AppLoader(label: 'Loading sounds…')) + : SettingsBody( + children: [ + _currentCard(), + SizedBox(height: 22.h), + const SettingsLabel('Choose a sound'), + SettingsGroup( + dividerIndent: 16, + rows: [for (final opt in _options) _soundRow(opt)], + ), + const SettingsHint( + 'Tap a sound to hear it and set it. Your phone must not be ' + 'on silent for booking alerts to come through.', + ), + SizedBox(height: 12.h), + ], + ), + ), + ); + } + + /// The one thing a rider opening this screen wants to know first: which + /// sound is live right now, and what does it sound like. + Widget _currentCard() { + return SettingsCard( + padding: EdgeInsets.all(16.w), + child: Row( + children: [ + Container( + width: 46.w, + height: 46.w, + decoration: BoxDecoration( + color: ColorConstants.primary.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon( + Icons.notifications_active_rounded, + color: ColorConstants.primary, + size: 23.sp, + ), + ), + SizedBox(width: 14.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _currentLabel, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + Text( + 'Plays when a new booking arrives', + style: TextStyle( + fontSize: 12.sp, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + _playButton( + _options.firstWhere( + (o) => o.assetPath == _selected, + orElse: () => _options.first, + ), + ), + ], + ), + ); + } + + Widget _soundRow(_SoundOption opt) { + final bool isSelected = _selected == opt.assetPath; + return SettingsRow( + icon: isSelected + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked_rounded, + iconColor: isSelected + ? ColorConstants.primary + : ColorConstants.onSurfaceVariant, + title: opt.label, + subtitle: isSelected ? 'Current alert' : null, + onTap: () => _select(opt), + trailing: _playButton(opt), + ); + } + + Widget _playButton(_SoundOption opt) { + final bool isPlaying = _playing == opt.assetPath; + return PressScale( + onTap: () => _preview(opt), + child: Container( + width: 38.w, + height: 38.w, + decoration: BoxDecoration( + color: isPlaying + ? ColorConstants.primary + : ColorConstants.neutralLight, + shape: BoxShape.circle, + ), + child: Icon( + isPlaying ? Icons.graphic_eq_rounded : Icons.play_arrow_rounded, + size: 20.sp, + color: isPlaying + ? ColorConstants.onPrimary + : ColorConstants.onSurfaceVariant, + ), + ), + ); + } +} + +class _SoundOption { + final String label; + final String assetPath; + const _SoundOption({required this.label, required this.assetPath}); +} diff --git a/lib/views/Dashboard/profile/informations/profile.dart b/lib/views/Dashboard/profile/informations/profile.dart new file mode 100644 index 0000000..792f98a --- /dev/null +++ b/lib/views/Dashboard/profile/informations/profile.dart @@ -0,0 +1,409 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:miler/controllers/profile_controller.dart'; +import 'package:miler/data/miler_api.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; + +class Profile extends StatefulWidget { + const Profile({super.key}); + + @override + State createState() => _ProfileState(); +} + +class _ProfileState extends State { + final _nameCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + final _contactCtrl = TextEditingController(); + final _addressCtrl = TextEditingController(); + bool _saving = false; + + /// The form is filled from SharedPreferences, which is a disk read. It is + /// usually a few milliseconds, but "usually" is doing a lot of work on a + /// budget phone mid-shift — and the failure mode was the worst kind: four + /// empty boxes and a live Save button, i.e. a screen that looks like the + /// rider's details were lost and invites him to overwrite them with blanks. + bool _loading = true; + + late final ProfileController _profileController = + Get.isRegistered() + ? Get.find() + : Get.put(ProfileController(), permanent: true); + + @override + void initState() { + super.initState(); + _loadProfile(); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _emailCtrl.dispose(); + _contactCtrl.dispose(); + _addressCtrl.dispose(); + super.dispose(); + } + + Future _loadProfile() async { + final prefs = await SharedPreferences.getInstance(); + _nameCtrl.text = prefs.getString('user_name') ?? ''; + _emailCtrl.text = prefs.getString('user_email') ?? ''; + _contactCtrl.text = prefs.getString('contactno') ?? ''; + _addressCtrl.text = prefs.getString('user_address') ?? ''; + if (mounted) setState(() => _loading = false); + } + + Future _pickImage() async { + try { + final picker = ImagePicker(); + final picked = await picker.pickImage( + source: ImageSource.gallery, + imageQuality: 70, + ); + if (picked == null) return; + _profileController.setImagePath(picked.path); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('profile_image_path', picked.path); + if (mounted) setState(() {}); + } catch (_) {} + } + + /// ── This used to save to the phone and nowhere else ── + /// + /// The old body wrote three `SharedPreferences` keys, waited 300ms and said + /// "Your details have been saved." The hub never saw any of it, so a rider + /// who corrected his own name still appeared under the old one on every + /// dispatch screen — and reinstalling the app silently reverted the change. + /// + /// The server is now the record and prefs are the local cache of it, in that + /// order. Note what is *not* sent: email and address have no field on + /// `PUT /miler/profile`, so they stay device-local and are called out in the + /// gap list rather than being quietly dropped. + Future _save() async { + FocusScope.of(context).unfocus(); + setState(() => _saving = true); + + final name = _nameCtrl.text.trim(); + final res = await MilerApi.updateProfile(displayName: name); + + if (!mounted) return; + + if (!res.ok) { + setState(() => _saving = false); + AppFeedback.error( + context, + res.message.isEmpty + ? "Couldn't save your details — check your signal and try again" + : res.message, + ); + return; + } + + // Only cache locally once the server has taken it, so the two cannot + // disagree about what the rider's name is. + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('user_name', name); + await prefs.setString('user_email', _emailCtrl.text.trim()); + await prefs.setString('user_address', _addressCtrl.text.trim()); + _profileController.setProfile( + name: _nameCtrl.text, + email: _emailCtrl.text, + address: _addressCtrl.text, + ); + + if (!mounted) return; + setState(() => _saving = false); + Get.back(); + AppFeedback.successGlobal('Your details have been saved.'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ColorConstants.surface, + appBar: milerPageBar('Edit Profile', onBack: () => Get.back()), + body: SmoothSwap( + child: _loading + ? _loadingForm() + : SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 24.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center(child: _buildAvatar()), + SizedBox(height: 8.h), + Center( + child: TextButton.icon( + onPressed: _pickImage, + icon: Icon( + Icons.photo_camera_outlined, + size: 16.sp, + color: ColorConstants.primary, + ), + label: Text( + 'Change Photo', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.primary, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + SizedBox(height: 16.h), + _field( + label: 'Full name', + controller: _nameCtrl, + icon: Icons.person_outline_rounded, + hint: 'Enter your name', + ), + SizedBox(height: 16.h), + _field( + label: 'Mobile number', + controller: _contactCtrl, + icon: Icons.phone_outlined, + hint: 'Mobile number', + readOnly: true, + prefix: '+91 ', + ), + SizedBox(height: 16.h), + _field( + label: 'Email address', + controller: _emailCtrl, + icon: Icons.mail_outline_rounded, + hint: 'you@example.com', + keyboardType: TextInputType.emailAddress, + ), + SizedBox(height: 16.h), + _field( + label: 'Address', + controller: _addressCtrl, + icon: Icons.location_on_outlined, + hint: 'Your address', + maxLines: 2, + ), + ], + ), + ), + ), + // No Save until there is something to save. + bottomNavigationBar: _loading + ? null + : SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 12.h), + child: PrimaryButton( + label: 'Save changes', + loading: _saving, + onPressed: _save, + ), + ), + ), + ); + } + + /// The shape of the form, shimmering — so the page the rider slid up into is + /// already the right layout when the real values land, rather than empty + /// boxes that reflow under his thumb. + Widget _loadingForm() { + return MilerShimmer( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 24.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: SkeletonBone(width: 104.w, height: 104.w, radius: 104.w), + ), + SizedBox(height: 16.h), + Center( + child: SkeletonBone(width: 120.w, height: 14.h), + ), + SizedBox(height: 24.h), + for (int i = 0; i < 4; i++) ...[ + SkeletonBone(width: 110.w, height: 13.h), + SizedBox(height: 8.h), + SkeletonBone(height: 52.h, radius: 14.r), + SizedBox(height: 16.h), + ], + ], + ), + ), + ); + } + + Widget _buildAvatar() { + return Obx(() { + final path = _profileController.imagePath.value; + bool hasImage = false; + try { + hasImage = path.isNotEmpty && File(path).existsSync(); + } catch (_) {} + return GestureDetector( + onTap: _pickImage, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 104.w, + height: 104.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ColorConstants.neutralLight, + border: Border.all( + color: ColorConstants.surfaceContainerLowest, + width: 3, + ), + boxShadow: [ + BoxShadow( + color: ColorConstants.onSurface.withValues(alpha: 0.06), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + image: hasImage + ? DecorationImage( + image: FileImage(File(path)), + fit: BoxFit.cover, + ) + : null, + ), + child: !hasImage + ? Icon( + Icons.person_rounded, + color: ColorConstants.onSurfaceVariant, + size: 52.sp, + ) + : null, + ), + Positioned( + bottom: 0, + right: 0, + child: Container( + width: 32.w, + height: 32.w, + decoration: BoxDecoration( + color: ColorConstants.primary, + shape: BoxShape.circle, + border: Border.all( + color: ColorConstants.surfaceContainerLowest, + width: 2, + ), + ), + child: Icon( + Icons.photo_camera_rounded, + color: ColorConstants.onPrimary, + size: 15.sp, + ), + ), + ), + ], + ), + ); + }); + } + + Widget _field({ + required String label, + required TextEditingController controller, + required IconData icon, + String? hint, + String? prefix, + bool readOnly = false, + int maxLines = 1, + TextInputType keyboardType = TextInputType.text, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.onSurface, + letterSpacing: 0.2, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 8.h), + TextField( + controller: controller, + readOnly: readOnly, + maxLines: maxLines, + keyboardType: keyboardType, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + color: readOnly + ? ColorConstants.onSurfaceVariant + : ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + decoration: InputDecoration( + hintText: hint, + prefixText: prefix, + prefixStyle: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + hintStyle: TextStyle( + fontSize: 14.sp, + color: ColorConstants.secondaryText, + fontWeight: FontWeight.w400, + fontFamily: FontConstants.fontFamily, + ), + prefixIcon: Icon( + icon, + color: readOnly + ? ColorConstants.secondaryText + : ColorConstants.onSurfaceVariant, + size: 20.sp, + ), + filled: true, + fillColor: readOnly + ? ColorConstants.neutralLight + : ColorConstants.surfaceContainerLowest, + contentPadding: EdgeInsets.symmetric( + horizontal: 16.w, + vertical: 14.h, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderSide: BorderSide(color: ColorConstants.dividerColor), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderSide: BorderSide(color: ColorConstants.dividerColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderSide: BorderSide(color: ColorConstants.primary, width: 2), + ), + suffixIcon: readOnly + ? Icon( + Icons.lock_outline_rounded, + size: 16.sp, + color: ColorConstants.secondaryText, + ) + : null, + ), + ), + ], + ); + } +} diff --git a/lib/views/Dashboard/profile/informations/rider_rewards_page.dart b/lib/views/Dashboard/profile/informations/rider_rewards_page.dart new file mode 100644 index 0000000..f35a18f --- /dev/null +++ b/lib/views/Dashboard/profile/informations/rider_rewards_page.dart @@ -0,0 +1,826 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import 'package:miler/controllers/rewards_controller.dart'; +import 'package:miler/data/accepted_store.dart'; +import 'package:miler/data/rider_tier.dart'; +import 'package:miler/data/stop_compliance.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/widget/Bottom_page.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// REWARDS — status, progress, compliance, rules. In that order. +/// +/// ── What this replaced ── +/// +/// A single gradient card with a number on it. It told the rider his balance +/// and nothing else: not what the number was worth, not what earned it, not +/// what he had to do next. Every loyalty-UX writeup names that as the failure +/// case — a balance with no context is a score in a game whose rules are +/// unpublished. +/// +/// ── The shape, and where it comes from ── +/// +/// Uber Pro is the version of this a miler has most likely already used, and +/// its driver hub is built in four moves. This page makes the same four: +/// +/// 1. **Where you stand** — the status, the balance, and the period they +/// belong to, at the very top. Voucherify's checklist puts it bluntly: +/// show what they've earned, what's next, and why it's worth it. +/// 2. **How far the next step is** — a bar and a number of points, never a +/// percentage. "180 points to Gold" is a thing a rider can act on today; +/// "62% of the way" is not. +/// 3. **The compliance score** — Uber gates its tiers on acceptance and +/// cancellation rates whatever the volume. The equivalents here are the two +/// things the hub already measures per stop: did he reach it inside the +/// ETA, did he ride the route he was given. Both are named on the page as +/// what the hub calls them — a compliance score, paying compliance points — +/// so the screen and the depot conversation use one vocabulary. +/// 4. **How points are earned**, stated in one line each. The single most +/// repeated warning in the research is against opaque conversions. +/// +/// ── What this page will not do ── +/// +/// It does not invent perks. What a status is worth is a commercial decision +/// belonging to the hub, and a rewards screen that promises benefits the +/// business has not agreed to is worse than one that promises none. The tier +/// thresholds are placeholders for the same reason and live in one named place +/// — see [kTierThresholds]. +/// ───────────────────────────────────────────────────────────────────────── +class RiderRewardsPage extends StatefulWidget { + const RiderRewardsPage({super.key}); + + @override + State createState() => _RiderRewardsPageState(); +} + +class _RiderRewardsPageState extends State { + late final RewardsController _rewards = Get.isRegistered() + ? Get.find() + : Get.put(RewardsController()); + + ComplianceSummary _summary = ComplianceSummary.empty; + bool _loaded = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final done = await getCompletedBookings(); + if (!mounted) return; + setState(() { + _summary = ComplianceSummary.from(done); + _loaded = true; + }); + } + + @override + Widget build(BuildContext context) { + final period = RewardPeriod.current(); + + return Scaffold( + backgroundColor: ColorConstants.surface, + appBar: milerPageBar('Rewards', onBack: () => Get.back()), + body: SafeArea( + child: RefreshIndicator( + onRefresh: _load, + color: ColorConstants.primary, + child: ListView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 28.h), + children: [ + Obx(() { + final standing = TierStanding.of(_rewards.totalPoints.value); + return Column( + children: [ + _statusCard(standing, period, _rewards.isLoading.value), + SizedBox(height: 10.h), + _ladder(standing), + ], + ); + }), + SizedBox(height: 22.h), + _complianceScore(), + SizedBox(height: 22.h), + _howToEarn(), + SizedBox(height: 18.h), + _hubNote(), + ], + ), + ), + ), + ); + } + + // ── 1 · Where you stand ──────────────────────────────────────────────── + // + // Status, balance and period on one surface, because they are one fact. The + // card stays brand maroon at every tier — only the badge changes — so the + // screen does not become a different product as the rider moves up. + Widget _statusCard(TierStanding s, RewardPeriod period, bool loading) { + final daysLeft = period.daysLeft(); + final toNext = s.pointsToNext; + + return Container( + width: double.infinity, + padding: EdgeInsets.all(18.r), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + gradient: LinearGradient( + colors: [ColorConstants.primaryContainer, ColorConstants.primary], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: ColorConstants.primary.withValues(alpha: 0.25), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 46.w, + height: 46.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.pureSurface.withValues(alpha: 0.16), + shape: BoxShape.circle, + border: Border.all( + color: s.tier.accent.withValues(alpha: 0.9), + width: 2, + ), + ), + child: Icon(s.tier.icon, size: 24.sp, color: s.tier.accent), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${s.tier.label} status', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + Text( + '${period.label} · ' + '$daysLeft ${daysLeft == 1 ? 'day' : 'days'} left', + style: TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.78), + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + // The balance, as the biggest thing on the card. + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + loading ? '—' : '${s.points}', + style: TextStyle( + fontSize: 30.sp, + fontWeight: FontWeight.w900, + letterSpacing: -1, + height: 1, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + Text( + 'points', + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w700, + letterSpacing: 0.4, + color: Colors.white.withValues(alpha: 0.78), + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ], + ), + + SizedBox(height: 16.h), + + // ── 2 · How far the next step is ── + // + // A count of points, not a percentage. "180 points to Gold" is + // something a rider can decide to do something about this afternoon. + Text( + toNext == null + ? 'Top status — hold it to the end of the period' + : toNext == 0 + ? 'Enough points for ${s.nextTier!.label} — meet the ' + 'standards below to move up' + : '$toNext more ${toNext == 1 ? 'point' : 'points'} to ' + '${s.nextTier!.label}', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 8.h), + ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + child: LinearProgressIndicator( + value: s.progressToNext, + minHeight: 8.h, + backgroundColor: Colors.white.withValues(alpha: 0.22), + valueColor: AlwaysStoppedAnimation(s.tier.accent), + ), + ), + ], + ), + ); + } + + /// The four statuses on one rail, current one marked. + /// + /// Uber's hub shows the whole ladder rather than just the next rung, and the + /// reason holds here: a rider deciding whether the push is worth it needs to + /// see how far the road goes, not only the next step on it. + Widget _ladder(TierStanding s) { + return Row( + children: [ + for (final tier in RiderTier.values) ...[ + Expanded( + child: Opacity( + opacity: tier.index <= s.tier.index ? 1 : 0.45, + child: Column( + children: [ + Container( + height: 4.h, + decoration: BoxDecoration( + color: tier.index <= s.tier.index + ? ColorConstants.primary + : ColorConstants.borderStrong, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + ), + SizedBox(height: 6.h), + Text( + tier.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.5.sp, + fontWeight: tier == s.tier + ? FontWeight.w800 + : FontWeight.w600, + color: tier == s.tier + ? ColorConstants.primary + : ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + Text( + '${tier.threshold}', + style: TextStyle( + fontSize: 9.5.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + if (tier != RiderTier.values.last) SizedBox(width: 6.w), + ], + ], + ); + } + + // ── 3 · What holds the status up: the compliance score ───────────────── + // + // Named, not implied. The hub judges a miler on two things it measures at + // every stop — did he reach it inside the ETA, did he ride the route he was + // given — and the rider's word for the pair is his *compliance*. Calling the + // block "your standards" left him to work out that this was the same thing + // the hub talks to him about, so the section says compliance, the figure is a + // compliance score, and what it pays is compliance points. + Widget _complianceScore() { + final s = _summary; + final nothingYet = _loaded && s.measured == 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel('COMPLIANCE SCORE'), + SizedBox(height: 4.h), + Text( + 'The share of today\'s stops that made both checks. It pays one ' + 'compliance point per clean stop, and a status needs ' + '$kStandardTarget% on each check to hold.', + style: _bodyStyle, + ), + SizedBox(height: 12.h), + Container( + width: double.infinity, + padding: EdgeInsets.all(16.r), + decoration: _cardDecoration, + child: !_loaded + ? SizedBox( + height: 76.h, + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: ColorConstants.primary, + ), + ), + ), + ) + : nothingYet + ? _nothingYet() + : Column( + children: [ + // The score itself, first and biggest: it is the one + // number the hub quotes back at him, and the two meters + // under it are the working that produced it. + _scoreHeader(s), + SizedBox(height: 16.h), + Divider(height: 1, color: ColorConstants.dividerColor), + SizedBox(height: 16.h), + _meter( + icon: Icons.schedule_rounded, + label: 'Reached inside the ETA', + hit: s.onTime, + total: s.measured, + percent: s.onTimePercent, + ), + SizedBox(height: 16.h), + _meter( + icon: Icons.alt_route_rounded, + label: 'Rode the assigned route', + hit: s.onRoute, + total: s.measured, + percent: s.onRoutePercent, + ), + SizedBox(height: 16.h), + Divider(height: 1, color: ColorConstants.dividerColor), + SizedBox(height: 12.h), + Row( + children: [ + Expanded( + child: Text( + '${s.clean} of ${s.measured} ' + '${s.measured == 1 ? 'stop' : 'stops'} today ' + 'cleared both checks', + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 8.w), + MilerButton( + label: 'See stops', + icon: Icons.history_rounded, + color: ColorConstants.primary, + height: ButtonSizes.compact, + expand: false, + onPressed: () { + Navigator.of(context).maybePop(); + BottomPage.goToTab(2); + }, + ), + ], + ), + // Unmeasured stops are stated, never scored. A rider + // must not lose status because the hub set no ETA or + // the GPS never resolved. + if (s.unmeasured > 0) ...[ + SizedBox(height: 10.h), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline_rounded, + size: 13.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 6.w), + Expanded( + child: Text( + '${s.unmeasured} other ' + '${s.unmeasured == 1 ? 'stop' : 'stops'} had ' + 'no ETA or distance recorded. Those do not ' + 'count against you.', + style: TextStyle( + fontSize: 11.5.sp, + height: 1.4, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ], + ], + ), + ), + ], + ); + } + + // ── 4 · How points are earned ────────────────────────────────────────── + // + // One line each, in the rider's units. The research is unanimous that opaque + // conversions are what kill trust in a rewards screen, and these are the + // actual rules the backend applies — not a simplified story about them. + Widget _howToEarn() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel('HOW POINTS AND COMPLIANCE WORK'), + SizedBox(height: 12.h), + Container( + decoration: _cardDecoration, + padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 6.h), + child: Column( + children: [ + _rule( + icon: Icons.timer_rounded, + title: 'Close a stop before its ETA', + body: + 'Points scale with the distance you rode to reach it. ' + 'Miss the ETA and that stop earns none.', + ), + _ruleDivider(), + _rule( + icon: Icons.alt_route_rounded, + title: 'Ride the route your hub assigned', + body: + 'Wandering well past the planned distance breaks the ' + 'route check for that stop.', + ), + _ruleDivider(), + _rule( + icon: Icons.verified_rounded, + title: 'Clear both checks for a compliance point', + body: + 'A stop that makes its ETA and stays on route earns one ' + 'compliance point. Your compliance score is the share of ' + 'stops that did.', + ), + _ruleDivider(), + _rule( + icon: Icons.event_repeat_rounded, + title: 'Points reset each month', + body: + 'Your status for the next period is set by the points ' + 'you finish this one with, and your compliance score has ' + 'to hold to keep it.', + last: true, + ), + ], + ), + ), + ], + ); + } + + /// What a status is worth is the hub's call, and the app says so rather than + /// inventing perks it cannot honour. + Widget _hubNote() { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.storefront_rounded, + size: 14.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 7.w), + Expanded( + child: Text( + 'Your hub sets what each status is worth and can change the ' + 'thresholds. Ask your hub manager what your level earns you.', + style: TextStyle( + fontSize: 11.5.sp, + height: 1.45, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ); + } + + // ── Pieces ───────────────────────────────────────────────────────────── + + Widget _sectionLabel(String text) => Text( + text, + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ); + + TextStyle get _bodyStyle => TextStyle( + fontSize: 12.5.sp, + height: 1.45, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ); + + BoxDecoration get _cardDecoration => BoxDecoration( + color: ColorConstants.surfaceContainerLowest, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: ColorConstants.dividerColor), + ); + + Widget _nothingYet() { + return Row( + children: [ + Icon( + Icons.pending_actions_rounded, + size: 22.sp, + color: ColorConstants.secondaryText, + ), + SizedBox(width: 10.w), + Expanded( + child: Text( + 'No stops finished yet today. Your standards are counted as you ' + 'complete them.', + style: _bodyStyle, + ), + ), + ], + ); + } + + /// The score, and what it paid: `80%` beside `4 compliance points`. + /// + /// Two figures rather than one because they answer different questions. The + /// score is how well he worked — it is the number that decides whether a + /// status holds. The points are what that work *earned*, and they are a count + /// rather than a share: twelve clean stops must not read the same as four. + Widget _scoreHeader(ComplianceSummary s) { + final int score = s.cleanPercent; + final bool meets = score >= kStandardTarget; + final Color accent = meets + ? ColorConstants.acceptGreen + : ColorConstants.warning; + + return Row( + children: [ + Container( + width: 62.w, + height: 62.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.10), + shape: BoxShape.circle, + border: Border.all(color: accent.withValues(alpha: 0.45), width: 2), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$score%', + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w900, + letterSpacing: -0.5, + height: 1, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 1.h), + Text( + 'SCORE', + style: TextStyle( + fontSize: 8.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + height: 1, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + SizedBox(width: 14.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.workspace_premium_rounded, + size: 17.sp, + color: ColorConstants.acceptGreen, + ), + SizedBox(width: 6.w), + Flexible( + child: Text( + '${s.points} compliance ' + '${s.points == 1 ? 'point' : 'points'} today', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + SizedBox(height: 3.h), + Text( + meets + ? 'Above the $kStandardTarget% your status needs' + : 'Your status needs $kStandardTarget% on each check', + style: TextStyle( + fontSize: 11.5.sp, + height: 1.35, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ); + } + + /// One standard: a count, a percentage, a bar, and whether it clears the bar. + Widget _meter({ + required IconData icon, + required String label, + required int hit, + required int total, + required int percent, + }) { + final bool meets = percent >= kStandardTarget; + // Amber below target, not red: this is a standing to lift, not a failure + // to report. Red in this app is reserved for things that went wrong. + final Color accent = meets + ? ColorConstants.acceptGreen + : ColorConstants.warning; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 15.sp, color: accent), + SizedBox(width: 7.w), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 8.w), + Text( + '$hit/$total · $percent%', + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + SizedBox(height: 7.h), + // The target is drawn on the track, so "am I clearing it" is a glance + // rather than a comparison against a number in the paragraph above. + LayoutBuilder( + builder: (context, c) => SizedBox( + height: 8.h, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + child: LinearProgressIndicator( + value: total == 0 ? 0 : hit / total, + minHeight: 8.h, + backgroundColor: ColorConstants.borderSubtle, + valueColor: AlwaysStoppedAnimation(accent), + ), + ), + Positioned( + left: c.maxWidth * (kStandardTarget / 100), + top: 0, + bottom: 0, + child: Container(width: 2, color: ColorConstants.slateText), + ), + ], + ), + ), + ), + ], + ); + } + + Widget _rule({ + required IconData icon, + required String title, + required String body, + bool last = false, + }) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 12.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 32.w, + height: 32.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: ColorConstants.primary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Icon(icon, size: 16.sp, color: ColorConstants.primary), + ), + SizedBox(width: 12.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 3.h), + Text(body, style: _bodyStyle), + ], + ), + ), + ], + ), + ); + } + + Widget _ruleDivider() => + Divider(height: 1, color: ColorConstants.dividerColor); +} diff --git a/lib/views/Dashboard/profile/informations/saved_address.dart b/lib/views/Dashboard/profile/informations/saved_address.dart new file mode 100644 index 0000000..290251b --- /dev/null +++ b/lib/views/Dashboard/profile/informations/saved_address.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/controllers/profile_controller.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/settings_ui.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// SAVED ADDRESS +/// +/// The rider's own address — where his shift starts and ends, and what the hub +/// reads when it works out which slots begin near him. +/// +/// ── What this page used to be ── +/// +/// A read-only card showing the address, an info panel explaining what it was +/// for, and a `Spacer()` holding the bottom half of the screen empty. It was +/// titled "Saved Addresses", plural, and offered no way to add, edit or remove +/// one. A rider whose address was wrong — or blank, which is the state every +/// new rider is in — could read that it was wrong and do nothing about it. The +/// only route to changing it was to guess that Edit Profile had a field for it. +/// +/// A settings page that states a fact the rider cannot act on is not a settings +/// page, it is a label. This one edits and saves, writing the same +/// `user_address` key Edit Profile uses and pushing it through +/// [ProfileController] so both screens agree the moment either changes. +/// ───────────────────────────────────────────────────────────────────────── +class SavedAddressPage extends StatefulWidget { + const SavedAddressPage({super.key}); + + @override + State createState() => _SavedAddressPageState(); +} + +class _SavedAddressPageState extends State { + final TextEditingController _controller = TextEditingController(); + final FocusNode _focus = FocusNode(); + + late final ProfileController _profileController = + Get.isRegistered() + ? Get.find() + : Get.put(ProfileController(), permanent: true); + + /// Until the stored address is actually read back, this page cannot honestly + /// say either "here is your address" or "nothing saved yet" — and it used to + /// assert the second one for a frame, which is a claim a rider acts on. + bool _loading = true; + bool _editing = false; + bool _saving = false; + String _saved = ''; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _controller.dispose(); + _focus.dispose(); + super.dispose(); + } + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final address = (prefs.getString('user_address') ?? '').trim(); + if (!mounted) return; + setState(() { + _saved = address; + _controller.text = address; + _loading = false; + // A rider with nothing saved is here to add one. Open into the field + // rather than making him find an Edit button first. + _editing = address.isEmpty; + }); + } + + Future _save() async { + FocusScope.of(context).unfocus(); + setState(() => _saving = true); + final value = _controller.text.trim(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('user_address', value); + _profileController.setProfile(address: value); + if (!mounted) return; + setState(() { + _saved = value; + _saving = false; + _editing = false; + }); + } + + void _startEditing() { + setState(() => _editing = true); + _focus.requestFocus(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ColorConstants.surface, + appBar: milerPageBar( + 'Saved Address', + onBack: () => Get.back(), + actions: [ + if (!_loading && !_editing && _saved.isNotEmpty) + TextButton( + onPressed: _startEditing, + child: Text( + 'Edit', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.primary, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + body: SmoothSwap( + child: _loading + ? _loadingBody() + : SettingsBody( + children: [ + _editing ? _editor() : _addressCard(), + const SettingsHint( + 'Your hub uses this to work out which slots start near you, ' + 'and it is where your shift is assumed to end.', + ), + SizedBox(height: 24.h), + ], + ), + ), + bottomNavigationBar: (_loading || !_editing) + ? null + : SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 12.h), + child: PrimaryButton( + label: _saved.isEmpty ? 'Save address' : 'Update address', + loading: _saving, + onPressed: _controller.text.trim().isEmpty ? null : _save, + ), + ), + ), + ); + } + + /// The saved address, at rest. + Widget _addressCard() { + final bool hasAddress = _saved.isNotEmpty; + return SettingsCard( + padding: EdgeInsets.all(16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44.w, + height: 44.w, + decoration: BoxDecoration( + color: ColorConstants.primary.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon( + Icons.home_outlined, + color: ColorConstants.primary, + size: 22.sp, + ), + ), + SizedBox(width: 14.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Home address', + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 6.h), + Text( + hasAddress ? _saved : 'No address saved yet.', + style: TextStyle( + fontSize: 13.5.sp, + height: 1.5, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + SizedBox(height: 14.h), + MilerButton( + label: hasAddress ? 'Change address' : 'Add your address', + icon: hasAddress ? Icons.edit_outlined : Icons.add_rounded, + variant: MilerButtonVariant.tonal, + height: ButtonSizes.secondary, + onPressed: _startEditing, + ), + ], + ), + ); + } + + /// The editor. Deliberately one plain multi-line field: a rider types his + /// address the way he would say it, and splitting it into house / street / + /// pincode boxes buys nothing when the value is only ever read by a human. + Widget _editor() { + return SettingsCard( + padding: EdgeInsets.all(16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Home address', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 8.h), + TextField( + controller: _controller, + focusNode: _focus, + minLines: 3, + maxLines: 4, + textCapitalization: TextCapitalization.words, + onChanged: (_) => setState(() {}), + style: TextStyle( + fontSize: 15.sp, + height: 1.45, + fontWeight: FontWeight.w600, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + decoration: InputDecoration( + hintText: 'House / street, area, city, pincode', + hintStyle: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w400, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + filled: true, + // A white field on a white card has no edge but its border. + // The neutral fill is what separates them. + fillColor: ColorConstants.neutralLight, + contentPadding: EdgeInsets.symmetric( + horizontal: 14.w, + vertical: 14.h, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderSide: BorderSide(color: ColorConstants.dividerColor), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderSide: BorderSide(color: ColorConstants.dividerColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderSide: BorderSide(color: ColorConstants.primary, width: 2), + ), + ), + ), + if (_saved.isNotEmpty) + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () { + FocusScope.of(context).unfocus(); + setState(() { + _controller.text = _saved; + _editing = false; + }); + }, + child: Text( + 'Cancel', + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _loadingBody() { + return MilerShimmer( + child: Padding( + padding: EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 28.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SkeletonBone(height: 150.h, radius: 18.r), + SizedBox(height: 16.h), + SkeletonBone(height: 34.h, radius: 10.r), + ], + ), + ), + ); + } +} diff --git a/lib/views/Dashboard/profile/informations/terms_condition.dart b/lib/views/Dashboard/profile/informations/terms_condition.dart new file mode 100644 index 0000000..f3a915c --- /dev/null +++ b/lib/views/Dashboard/profile/informations/terms_condition.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/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; + debugPrint('Started loading: $url'); + }, + onPageFinished: (url) { + isLoading.value = false; + debugPrint('Finished loading: $url'); + }, + onWebResourceError: (error) { + isLoading.value = false; + debugPrint('WebView error: ${error.description}'); + }, + ), + ); + loadTermsUrl(); + } + + Future loadTermsUrl() async { + if (webViewController != null) { + try { + await webViewController!.loadRequest( + Uri.parse('https://doormile.in/terms'), + ); + } catch (e) { + debugPrint('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( + backgroundColor: ColorConstants.surface, + appBar: milerPageBar('Terms & Conditions', onBack: () => Get.back()), + body: SafeArea( + child: Obx(() { + final wvc = controller.webViewController; + final loading = controller.isLoading.value; + return Stack( + children: [ + if (wvc != null) WebViewWidget(controller: wvc), + // A hairline progress bar over a blank white page told the rider + // nothing was happening. While the document is on its way, say so + // in the app's own voice — and cover the blank page while doing + // it, so the text does not appear to arrive from behind a bar. + if (loading) + Positioned.fill( + child: Container( + color: ColorConstants.surface, + child: const AppLoader(label: 'Loading terms…'), + ), + ), + ], + ); + }), + ), + ); + } +} diff --git a/lib/views/Dashboard/summary/summary.dart b/lib/views/Dashboard/summary/summary.dart new file mode 100644 index 0000000..11750e4 --- /dev/null +++ b/lib/views/Dashboard/summary/summary.dart @@ -0,0 +1,927 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:lottie/lottie.dart' hide Marker; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:miler/Models/summary/riderweeklykms.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; +import 'package:miler/controllers/summary_controller.dart'; +import 'package:miler/controllers/rewards_controller.dart'; +import 'package:miler/controllers/riderkm.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/helpers/constants/miler_type.dart'; + +class Summary extends StatefulWidget { + const Summary({super.key}); + + @override + State createState() => _SummaryState(); +} + +class _SummaryState extends State { + late final SummaryController controller; + late final RewardsController _rewards; + int _userId = 0; + int _refreshTick = 0; + int _selectedPeriod = 0; // 0=Today, 1=Week, 2=Month + + final List _periods = ['Today', 'This Week', 'This Month']; + + @override + void initState() { + super.initState(); + controller = Get.isRegistered() + ? Get.find() + : Get.put(SummaryController()); + _rewards = Get.isRegistered() + ? Get.find() + : Get.put(RewardsController()); + WidgetsBinding.instance.addPostFrameCallback((_) { + _refreshData(); + }); + } + + @override + void dispose() { + super.dispose(); + } + + 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); + _rewards.fetchBonusSummary(uid); + } + } catch (e) { + debugPrint('Error fetching summary: $e'); + } finally { + if (mounted) { + setState(() => _refreshTick++); + } + } + } + + int get _periodCount { + switch (_selectedPeriod) { + case 1: + return controller.week.value; + case 2: + return controller.month.value; + default: + return controller.today.value; + } + } + + int get _successRate { + final total = controller.total.value; + final cancelled = controller.cancelled.value; + final attempts = total + cancelled; + if (attempts <= 0) return 0; + return ((total / attempts) * 100).round(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: ColorConstants.surface, + // Earnings is no longer a tab — it opens from Account — so it needs a way + // back that the four tabs never did. + appBar: const MilerAppBar(title: 'Earnings', showBack: true), + body: RefreshIndicator( + onRefresh: _refreshData, + color: ColorConstants.primary, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + slivers: [ + SliverToBoxAdapter( + child: Column( + children: [ + SizedBox(height: 16.h), + _buildPeriodTabs(), + SizedBox(height: 16.h), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: _buildPickupsHero(), + ), + SizedBox(height: 16.h), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: _buildStatsGrid(), + ), + SizedBox(height: 16.h), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: TotalDistanceCard( + userId: _userId, + refreshTick: _refreshTick, + ), + ), + SizedBox(height: 32.h), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildPeriodTabs() { + return Container( + margin: EdgeInsets.symmetric(horizontal: 16.w), + height: 44.h, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: ColorConstants.outlineVariant), + ), + child: Row( + children: List.generate(_periods.length, (index) { + final isSelected = _selectedPeriod == index; + return Expanded( + child: GestureDetector( + onTap: () { + if (mounted) { + setState(() { + _selectedPeriod = index; + }); + } + }, + child: Container( + margin: EdgeInsets.all(4.r), + decoration: BoxDecoration( + color: isSelected + ? ColorConstants.primary + : Colors.transparent, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + alignment: Alignment.center, + child: Text( + _periods[index], + style: TextStyle( + fontSize: 13.sp, + fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, + color: isSelected + ? Colors.white + : ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ); + }), + ), + ); + } + + Widget _buildPickupsHero() { + final periodCount = _periodCount; + final total = controller.total.value; + final cancelled = controller.cancelled.value; + final points = _rewards.totalPoints.value; + + return Container( + width: double.infinity, + // ── Flat, like the rest of the app ── + // + // A two-stop gradient plus a 20px coloured shadow, on the one tab whose + // neighbours are all flat fills and hairlines. Earnings was visibly from + // a different product. The brand colour still owns this block — it is the + // one celebratory surface in the app and keeps that — but it is a fill, + // not an effect. The two faint circles behind it survive: they are what + // stops a solid maroon slab reading as a error state. + decoration: BoxDecoration( + color: ColorConstants.primary, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + child: Stack( + children: [ + Positioned( + top: -34, + right: -24, + child: Container( + width: 130.w, + height: 130.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ColorConstants.pureSurface.withValues(alpha: 0.07), + ), + ), + ), + Positioned( + bottom: -46, + left: -28, + child: Container( + width: 150.w, + height: 150.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ColorConstants.pureSurface.withValues(alpha: 0.05), + ), + ), + ), + Padding( + padding: EdgeInsets.all(20.r), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_periods[_selectedPeriod].toUpperCase()} · PICKUPS/DELIVERIES', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.8), + fontSize: 11.sp, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 14.h), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 320), + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: ScaleTransition( + scale: Tween( + begin: 0.82, + end: 1.0, + ).animate(anim), + child: child, + ), + ), + // 46 → 28, the scale's hero size. A 46sp figure sharing + // a row with a label is the app's highest overflow risk + // at 2x text, and it was 18pt larger than the largest + // type anywhere else in the product. + child: Text( + '$periodCount', + key: ValueKey(periodCount), + style: MilerType.hero.on(ColorConstants.onAccent), + ), + ), + SizedBox(width: 8.w), + Padding( + padding: EdgeInsets.only(bottom: 6.h), + child: Text( + periodCount == 1 ? 'order' : 'orders', + style: MilerType.label.on( + ColorConstants.onAccent.withValues(alpha: 0.75), + ), + ), + ), + ], + ), + SizedBox(height: 4.h), + Text( + '$total completed all-time', + style: MilerType.label.on( + ColorConstants.onAccent.withValues(alpha: 0.75), + ), + ), + SizedBox(height: 20.h), + Row( + children: [ + Expanded( + child: _buildHeroMetric( + '$_successRate%', + 'Success rate', + icon: Icons.percent_rounded, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: _buildHeroMetric( + '$cancelled', + 'Cancelled', + icon: Icons.do_not_disturb_on_rounded, + ), + ), + SizedBox(width: 12.w), + Expanded( + child: _buildHeroMetric( + '$points', + 'Reward pts', + icon: Icons.stars_rounded, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// One chip on the hero. + /// + /// The three used to be bare numbers over bare words — 92%, 4, 1,240 — which + /// on a saturated red panel is three identical grey-white blocks the rider + /// has to *read* to tell apart. A glyph makes each one findable at a glance, + /// which is the whole job of a hero: the number you came for, without + /// reading. Same rounded family as the cards below, at chip weight. + Widget _buildHeroMetric(String value, String label, {IconData? icon}) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 10.h), + decoration: BoxDecoration( + color: ColorConstants.pureSurface.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 14.sp, + color: Colors.white.withValues(alpha: 0.85), + ), + SizedBox(height: 6.h), + ], + Text( + value, + style: TextStyle( + color: Colors.white, + fontSize: 15.sp, + fontWeight: FontWeight.w800, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + Text( + label, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.65), + fontSize: 10.sp, + fontWeight: FontWeight.w500, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } + + /// ── Lifetime stops ── + /// + /// Three stat cards, in the same language Home uses for a trip's duration, + /// distance and cash: a tinted icon disc, the figure, a small-caps caption. + /// Two screens showing the same *kind* of thing — a small set of headline + /// numbers — should not invent two ways to draw it, and this is the one the + /// rider sees first every morning. + /// + /// ── What this replaced, and why twice ── + /// + /// First it was two floating shadowed cards with a gap. Then one bordered + /// container split by a hairline, which fixed the wrong thing: it presented + /// the numbers as two unrelated metrics sharing a row when they are two parts + /// of one quantity. Then a proportional share bar, which showed the split + /// honestly but spent a lot of height on a figure — the success rate — that is + /// a single number and reads perfectly well as one. + /// + /// So the rate is now simply the third card. It is the figure a rider is + /// judged on and the one he is proud of, and giving it equal billing with the + /// counts says that more plainly than a bar underneath them did. + Widget _buildStatsGrid() { + final total = controller.total.value; + final cancelled = controller.cancelled.value; + final attempts = total + cancelled; + final rate = attempts > 0 ? ((total / attempts) * 100).round() : 0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: 4.w, bottom: 10.h), + child: Text( + 'LIFETIME', + style: TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w800, + 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, + ), + ), + 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, + style: TextStyle( + fontSize: 9.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.7, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } +} + +/// Weekly distance chart backed by the real `getriderweeklykms` endpoint. +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 { + final RiderWeeklyKmController _kmController = RiderWeeklyKmController(); + late Future> _futureKms; + int _touchedIndex = -1; + + @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) { + // didUpdateWidget already runs as part of a rebuild, so assign directly. + // Calling setState() here re-enters the parent scroll view's viewport + // mid-update and trips the `!_doingMountOrUpdate` assertion. + _futureKms = _fetchWeeklyKms(); + } + } + + Future> _fetchWeeklyKms() async { + if (widget.userId <= 0) { + return _fallbackWeeklyKms(); + } + try { + final res = await _kmController.getRiderWeeklyKms(widget.userId); + final details = (res['details'] as List?) ?? const []; + if (details.isEmpty) return _fallbackWeeklyKms(); + return res; + } catch (e) { + debugPrint('[WEEKLY_KMS] $e'); + return _fallbackWeeklyKms(); + } + } + + /// Nothing to chart. + /// + /// This used to return a week of seeded demo kilometres in debug builds. A + /// distance chart feeds a payout, so an invented week is the one thing this + /// card must not draw — and "debug only" is no protection when the person + /// 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}; + + String _shortDay(String day) => day.length <= 3 ? day : day.substring(0, 3); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(18.r), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + boxShadow: const [ + BoxShadow( + color: Color(0x0A000000), + blurRadius: 20, + offset: Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // `route_rounded` is a branching-path glyph — it reads as + // "route planning", which is the hub's job, not as distance the + // rider has already covered. A speedometer is the thing on his + // handlebars that counts exactly this, and it needs no tinted + // tile to be recognisable at 18sp. + // `speed_rounded` is a speedometer, and this card counts + // 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, + color: ColorConstants.primary, + size: 20.sp, + ), + SizedBox(width: 9.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Weekly Distance', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + Text( + 'Kilometres ridden per day', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.sp, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ], + ), + SizedBox(height: 20.h), + FutureBuilder>( + future: _futureKms, + builder: (context, snapshot) { + // A spinner in the space a chart is about to occupy tells the + // rider nothing about what is coming and then hands over in one + // frame. A block the size of the chart does both jobs. + if (snapshot.connectionState == ConnectionState.waiting) { + return SmoothSwap( + child: MilerShimmer( + key: const ValueKey('kms-loading'), + child: SkeletonBone(height: 180.h, radius: 16), + ), + ); + } + final details = + (snapshot.data?['details'] as List?) ?? + []; + final totalKms = (snapshot.data?['total_kms'] as double?) ?? 0.0; + if (details.isEmpty) { + return SmoothSwap( + child: KeyedSubtree( + key: const ValueKey('kms-empty'), + child: _buildEmptyChart(), + ), + ); + } + return SmoothSwap( + child: KeyedSubtree( + key: const ValueKey('kms-chart'), + child: _buildChart(details, totalKms), + ), + ); + }, + ), + ], + ), + ); + } + + Widget _buildEmptyChart() { + return SizedBox( + height: 190.h, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 120.w, + height: 120.w, + child: Lottie.asset( + 'assets/lotties/no_distance.json', + fit: BoxFit.contain, + repeat: true, + ), + ), + SizedBox(height: 6.h), + Text( + 'No distance recorded yet', + style: TextStyle( + fontSize: 13.sp, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ); + } + + Widget _buildChart(List details, double totalKms) { + final values = details.map((e) => e.kms).toList(); + final maxVal = values.reduce((a, b) => a > b ? a : b); + final safeMax = maxVal <= 0 ? 1.0 : maxVal; + + // Best day + averages for the summary row. + int bestIdx = 0; + for (int i = 1; i < values.length; i++) { + if (values[i] > values[bestIdx]) bestIdx = i; + } + final avgPerDay = details.isEmpty ? 0.0 : totalKms / details.length; + + return Column( + children: [ + SizedBox( + height: 160.h, + child: BarChart( + BarChartData( + maxY: safeMax * 1.3, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: safeMax / 3, + getDrawingHorizontalLine: (value) => FlLine( + color: ColorConstants.surfaceContainerLow, + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + alignment: BarChartAlignment.spaceAround, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIndex, rod, rodIndex) { + return BarTooltipItem( + '${rod.toY.toStringAsFixed(1)} km', + TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 12.sp, + fontFamily: FontConstants.fontFamily, + ), + ); + }, + ), + touchCallback: (event, response) { + if (!mounted) return; + final int newIndex = + (response != null && + response.spot != null && + event is! FlTapUpEvent) + ? response.spot!.touchedBarGroupIndex + : -1; + if (newIndex != _touchedIndex) { + Future.microtask(() { + if (mounted) { + setState(() { + _touchedIndex = newIndex; + }); + } + }); + } + }, + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 30, + getTitlesWidget: (value, _) { + final idx = value.toInt(); + if (idx >= 0 && idx < details.length) { + final isTouched = idx == _touchedIndex; + return Padding( + padding: EdgeInsets.only(top: 8.h), + child: Text( + _shortDay(details[idx].day), + style: TextStyle( + fontSize: 11.sp, + fontWeight: isTouched + ? FontWeight.w800 + : FontWeight.w500, + color: isTouched + ? ColorConstants.primary + : ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ), + barGroups: List.generate(values.length, (i) { + final isTouched = i == _touchedIndex; + return BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: values[i], + // Flat, and the selected bar carries the brand while the + // rest stay neutral — the comparison is the point of a + // bar chart, and a gradient on every bar competes with it. + color: isTouched + ? ColorConstants.primary + : ColorConstants.outlineVariant, + width: 18.w, + borderRadius: BorderRadius.vertical( + top: Radius.circular(5.r), + ), + ), + ], + ); + }), + ), + ), + ), + SizedBox(height: 12.h), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildChartStat( + 'Best Day', + _shortDay(details[bestIdx].day), + ColorConstants.warning, + ), + _buildChartStat( + 'Total', + '${totalKms.toStringAsFixed(1)} km', + ColorConstants.primary, + ), + _buildChartStat( + 'Avg/Day', + '${avgPerDay.toStringAsFixed(1)} km', + ColorConstants.tertiary, + ), + ], + ), + ], + ); + } + + Widget _buildChartStat(String label, String value, Color color) { + return Column( + children: [ + Text( + value, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w800, + color: color, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + Text( + label, + style: TextStyle( + fontSize: 10.sp, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ); + } +} diff --git a/lib/views/helpers/constants/Colorconstants.dart b/lib/views/helpers/constants/Colorconstants.dart new file mode 100644 index 0000000..4dbf154 --- /dev/null +++ b/lib/views/helpers/constants/Colorconstants.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; + +/// Every colour in the app. +class ColorConstants { + // --- Rider Operations System Design System --- + + /// The page itself. Every other surface is measured against this. + static const Color surface = Color(0xFFFCF9F8); + static const Color surfaceDim = Color(0xFFDCD9D9); + static const Color surfaceBright = Color(0xFFFCF9F8); + + /// The lightest container — a white card. + static const Color surfaceContainerLowest = Color(0xFFFFFFFF); + static const Color surfaceContainerLow = Color(0xFFF6F3F2); + static const Color surfaceContainer = Color(0xFFF0EDED); + static const Color surfaceContainerHigh = Color(0xFFEAE7E7); + static const Color surfaceContainerHighest = Color(0xFFE4E2E1); + + static const Color onSurface = Color(0xFF1B1C1C); + + /// Body text on a surface. **Was #5C403E — a brown**, the M3 tonal derivation + /// of the red brand seed, on 56 call sites and in the app's `TextTheme`. Its + /// contrast was fine (9.30:1); it simply was not the same app as the slate + /// dashboard. Now a slate of matched luminance (9.53:1), so nothing gets + /// lighter and nothing is brown. + static const Color onSurfaceVariant = Color(0xFF3D4654); + + static const Color inverseSurface = Color(0xFF303030); + static const Color inverseOnSurface = Color(0xFFFCF9F8); + static const Color outline = Color(0xFF906F6D); + + /// Default outline. **Was #E5BDBB — a pink**, again derived from the red seed, + /// and it fed `ColorScheme.outlineVariant` plus every `OutlinedButton` side and + /// `TextField` border in [AppTheme]. So every input in the app had a pink edge. + /// Now the same slate hairline the dashboard uses for container edges. + static const Color outlineVariant = Color(0xFFCBD5E1); + static const Color surfaceTint = Color(0xFF960019); + + // Primary Red + static const Color primary = Color(0xFF960019); + static const Color onPrimary = Color(0xFFFFFFFF); + static const Color primaryContainer = Color(0xFFC01227); + static const Color onPrimaryContainer = Color(0xFFFFD1CE); + static const Color inversePrimary = Color(0xFFFFB3AF); + + // Secondary Dark + static const Color secondary = Color(0xFF5F5E5E); + static const Color onSecondary = Color(0xFFFFFFFF); + static const Color secondaryContainer = Color(0xFFE2DFDE); + static const Color onSecondaryContainer = Color(0xFF636262); + + // Tertiary (Info Blue) + static const Color tertiary = Color(0xFF004E6F); + static const Color onTertiary = Color(0xFFFFFFFF); + static const Color tertiaryContainer = Color(0xFF006791); + static const Color onTertiaryContainer = Color(0xFFB8E1FF); + + // Error + static const Color error = Color(0xFFBA1A1A); + static const Color onError = Color(0xFFFFFFFF); + static const Color errorContainer = Color(0xFFFFDAD6); + static const Color onErrorContainer = Color(0xFF93000A); + + // Success Green + static const Color success = Color(0xFF006A46); + static const Color onSuccess = Color(0xFFFFFFFF); + static const Color successContainer = Color(0xFF008659); + + /// The "go" green: Accept, accept-all, slide-to-duty, and the tick that marks + /// a finished stop. + /// + /// **Was #12B76A, which failed WCAG outright as a button fill.** White text on + /// it measured **2.62:1** — under even the 3:1 floor for large text and + /// graphics — which means the label on the single most-pressed button in the + /// 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); + + /// 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. + static const Color moneyGreen = acceptGreen; + + /// Reward points — the app's third currency, after cash ([moneyGreen]) and + /// completed work ([acceptGreen]). + /// + /// Deliberately *not* [warning]: amber means "attention, something is + /// unfinished" everywhere else in this app, and a points balance is good news. + static const Color rewardAmber = Color(0xFF92400E); + + // Warning Amber + // + // #B45309 → #AE5008. A three-percent darkening, for one reason: it cleared + // 4.5:1 on white (5.02) and on the page (4.79) but landed at **4.36:1 on + // `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); + static const Color onWarning = Color(0xFFFFFFFF); + static const Color warningContainer = Color(0xFFFEF3C7); + + // Extended Color Palette for Professional UI + /// Captions, labels and secondary body text — the second most-used colour in + /// the app after [slateText], on 89 call sites across every screen. + /// + /// **Was #8E8E93, which fails WCAG AA.** It measured 3.26:1 on white, against + /// a 4.5:1 floor — so on a phone clamped to a handlebar in daylight, every + /// caption in the app was the one thing a rider could not read. It is now + /// 6.00:1 on white. + static const Color secondaryText = Color(0xFF5A6472); + + /// ── A fill, never a text colour ── + /// + /// #C7C7CC measures **1.68:1 on white** — below even the 3:1 floor for large + /// text. It was being used for readable captions and hints on seven screens, + /// including the helper line under the phone field on the first screen a new + /// rider ever sees. + /// + /// Renamed from `disabledText` so the name cannot invite the mistake again. + /// A genuinely disabled *control* may be filled with this; the words on it + /// use [secondaryText]. + static const Color disabledFill = Color(0xFFC7C7CC); + + static const Color dividerColor = Color(0xFFE9EBF0); + static const Color upiPurple = Color(0xFF7C3AED); + static const Color successLight = Color(0xFFF0FFF4); + static const Color errorLight = Color(0xFFFFF0F0); + static const Color neutralLight = Color(0xFFF2F4F8); + + /// Reject, and inline validation errors. + static const Color errorRed = Color(0xFFB91C1C); + + // ── Five tokens retired here ── + // + // `successGreen` #34C759 (2.22:1), `infoBlue` #007AFF (4.02:1), + // `warningOrange` #FF9500 (2.20:1), `cardBlue` #0EA5E9 (2.77:1) and + // `warningYellow` #FFB800 (1.73:1) were a leftover iOS-derived set living + // alongside the tuned palette. Every one of them failed WCAG AA as text, and + // `warningOrange` was carrying the "you skipped this, this is the retry" + // notice *as text* — the one class of message a rider must not miss was the + // one that disappeared in sunlight. + // + // They are gone rather than fixed, because the tuned equivalents already + // exist and already pass: [warning] 5.02:1, [acceptGreen] 5.48:1, + // [tertiary], [deliveryAccent] 5.17:1. Two palettes for one set of meanings + // was the actual defect. + + // ── Stop-type accents ──────────────────────────────────────────────── + // One accent per operational mode so a rider never confuses what he is + // supposed to DO at a stop. These are the single source — `stop_type.dart` + // reads them, nothing else should hardcode these hex values. + static const Color pickupAccent = primary; + // #2563EB → #2462E8, for the same reason as [warning]: 4.49:1 on + // `cardSurface`, a hair under the floor, on the accent that names every + // delivery leg in the app. + static const Color deliveryAccent = Color(0xFF2462E8); + static const Color combinedAccent = Color(0xFF5B21B6); + + // ── Stop-type CHIP fills ───────────────────────────────────────────── + // Darkened siblings of the accents above, for the small filled tag that names + // a stop's mode. A tag is a mark, not a surface. + static const Color pickupChip = Color(0xFF7F0014); + static const Color deliveryChip = Color(0xFF1E40AF); + static const Color combinedChip = Color(0xFF4C1D95); + + /// Text drawn *on* a chip fill. + static const Color onChip = Color(0xFFFFFFFF); + + /// ── Borderless card fill ───────────────────────────────────────────── + /// + /// For cards that carry no border and no shadow, and rely on a fill to read as + /// a distinct surface — the pattern most large apps use for list cards. + /// + /// Measured at a 9.4% relative-luminance drop from the page: riders reported + /// being unable to tell cards apart when a card sat ~3% from its background, + /// and with no border and no shadow the fill carries that alone, outdoors. + static const Color cardSurface = Color(0xFFEDEFF4); + + /// ── The white a surface is actually made of ───────────────────────── + /// + /// The panel *inside* a card — the stat strip on an Activity row, the fact + /// tiles on the verification review. + static const Color pureSurface = Color(0xFFFFFFFF); + + /// ── The neutral action colour ── + /// + /// The colour Uber uses for anything that is *chrome* rather than brand: the + /// selected tab, a switch that is on, the fill behind a chosen option. + /// + /// Those were all the brand red at 10–14% alpha, which reads as a faint blush + /// and is not a brand moment. The red is still the red where it means + /// something: the primary button, the stop-type chips, a destructive action. + /// + /// The rule Uber follows, and this now does: colour marks meaning, neutral + /// marks state. + static const Color emphasis = Color(0xFF0F172A); + + /// The wash behind a selected row or tab — [emphasis] at the weight that + /// reads on the page without becoming a surface of its own. + static const Color emphasisWash = Color(0x140F172A); + + /// The same wash in the brand hue, for the one place the selected state is + /// deliberately branded rather than neutral: the bottom nav's travelling + /// pill. Kept at [emphasisWash]'s exact 8% so swapping the hue cannot also + /// change how heavy the pill reads. + static const Color primaryWash = Color(0x14960019); + + /// Text and icons drawn on a filled accent — brand red, accept green, a dark + /// snackbar. + static const Color onAccent = Color(0xFFFFFFFF); + + /// ── The frosted brand wash on every app bar ─────────────────────────── + /// + /// A translucent maroon laid over the page rather than a solid slab of it. + /// Painted behind a blur (see `milerGlassSurface`), so what shows through is + /// the page's own colour softened and tinted. + /// + /// Deliberately weak. At 7% the surface stays light enough for near-black + /// type, which is what keeps a title readable through a windscreen mount in + /// sun. + static const Color glassRed = Color(0x12960019); + + /// ── The fill on a frosted sheet ── + /// + /// A sheet over a map has something worth seeing underneath it, and a solid + /// slab throws all of it away. Frosted, the map stays legible as context while + /// the sheet stays legible as content. + /// + /// Deliberately high at 88%. This is not decoration: the text over it is an + /// address and an ETA, read one-handed at a doorstep in daylight, and every + /// point of transparency spent on the effect is contrast taken off the words. + static const Color glassSheet = Color(0xE0FDFDFE); + + /// The specular rim along a glass edge — what makes frost read as a pane + /// rather than as a translucent rectangle. + static const Color glassRim = Color(0x8CFFFFFF); + + // ── 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); + + /// The app's near-black body colour. + static const Color slateText = Color(0xFF0F172A); + + /// Card/section borders. `borderStrong` is the default for anything that + /// must read as a distinct container in sunlight; `borderSubtle` is only + /// for internal dividers inside an already-bordered card. + static const Color borderStrong = Color(0xFFCBD5E1); + static const Color borderSubtle = Color(0xFFE2E8F0); + + // Task categories + static Color parcelColor = 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]!; +} + +extension ColorExtension on String { + Color? toColor() { + var hexColor = replaceAll("#", ""); + if (hexColor.length == 6) { + hexColor = "FF$hexColor"; + } + if (hexColor.length == 8) { + return Color(int.parse("0x$hexColor")); + } + return null; + } +} diff --git a/lib/views/helpers/constants/Font_constant.dart b/lib/views/helpers/constants/Font_constant.dart new file mode 100644 index 0000000..0296ec2 --- /dev/null +++ b/lib/views/helpers/constants/Font_constant.dart @@ -0,0 +1,136 @@ +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'; + + // Typography scale + static const double displayLg = 32.0; + static const double headlineMd = 24.0; + static const double headlineSm = 20.0; + static const double bodyLg = 18.0; + static const double bodyMd = 16.0; + static const double labelBold = 14.0; + static const double labelSm = 12.0; + + static TextStyle displayLgStyle = const TextStyle( + fontSize: 32, + fontWeight: FontWeight.w700, + letterSpacing: -0.64, + fontFamily: fontFamily, + ); + + static TextStyle headlineMdStyle = const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + letterSpacing: -0.24, + fontFamily: fontFamily, + ); + + static TextStyle headlineSmStyle = const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + fontFamily: fontFamily, + ); + + static TextStyle bodyLgStyle = const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + fontFamily: fontFamily, + ); + + static TextStyle bodyMdStyle = const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + fontFamily: fontFamily, + ); + + static TextStyle labelBoldStyle = const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + fontFamily: fontFamily, + ); + + static TextStyle labelSmStyle = const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + fontFamily: fontFamily, + ); + + static double getResponsiveFontSize(BuildContext context, double baseSize) { + return baseSize; + } + + static double extraSmall(BuildContext context) => + getResponsiveFontSize(context, 10); + static double small(BuildContext context) => + getResponsiveFontSize(context, labelSm); + static double medium(BuildContext context) => + getResponsiveFontSize(context, bodyMd); + static double regular(BuildContext context) => + getResponsiveFontSize(context, bodyLg); + static double large(BuildContext context) => + getResponsiveFontSize(context, headlineSm); + static double xLarge(BuildContext context) => + getResponsiveFontSize(context, headlineMd); + static double xxLarge(BuildContext context) => + getResponsiveFontSize(context, 28); + static double xxxLarge(BuildContext context) => + getResponsiveFontSize(context, 30); + static double huge(BuildContext context) => + getResponsiveFontSize(context, displayLg); +} + +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/views/helpers/constants/apiconstants.dart b/lib/views/helpers/constants/apiconstants.dart new file mode 100644 index 0000000..f90e574 --- /dev/null +++ b/lib/views/helpers/constants/apiconstants.dart @@ -0,0 +1,90 @@ +class ApiConstants { + static String mainDev = "dev"; + static String mainRoute = "live"; + + // ── There is no maps key any more ── + // + // A `googleMapsApiKey` used to live here, feeding `flutter_polyline_points` + // and both native projects. It was revoked, so every tile request and every + // Directions call came back `REQUEST_DENIED` and the app looked broken rather + // than unconfigured. + // + // The maps no longer need one: tiles come from OpenStreetMap and routes from + // OSRM, both keyless. See `lib/views/helpers/widgets/miler_map.dart` and + // `lib/helpers/miler_router.dart` — including the note on what those free + // services are and are not suitable for. + + //Collection Queue - v2 + static String pickupQueueDev = + "https://jupiter.doormile.app/$mainDev/api/v2/pickups/getpickupqueues"; + static String pickupQueueLive = + "https://jupiter.doormile.app/$mainRoute/api/v2/pickups/getpickupqueues"; + + //Current Collection - v1 + static String currentPickupDev = + "https://jupiter.doormile.app/$mainDev/api/v1/pickups/getpickups"; + static String currentPickupLive = + "https://jupiter.doormile.app/$mainRoute/api/v1/pickups/getpickups"; + + //Current Collection V3 - v3 (date-bounded) + static String currentPickupV3Dev = + "https://jupiter.doormile.app/$mainDev/api/v3/pickups/getpickups"; + static String currentPickupV3Live = + "https://jupiter.doormile.app/$mainRoute/api/v3/pickups/getpickups"; + + //Update Collection - v1 + static String updatePickupDev = + "https://queue.workolik.com/live/api/v1/pickups/updatepickup"; + static String updatePickupLive = + "https://queue.workolik.com/live/api/v1/pickups/updatepickup"; + + //Get Rider Log - v1 + static String getRiderLogDev = + "https://jupiter.doormile.app/$mainDev/api/v1/partners/getriderlog"; + static String getRiderLogLive = + "https://jupiter.doormile.app/$mainRoute/api/v1/partners/getriderlog"; + + //Create Rider Log - v2 + static String createRiderLogDev = + "https://queue.workolik.com/live/api/v2/partners/createriderlog"; + static String createRiderLogLive = + "https://queue.workolik.com/live/api/v2/partners/createriderlog"; + + //Update Rider Log - v1 + static String updateRiderLogDev = + "https://jupiter.doormile.app/$mainDev/api/v1/partners/updateriderlog"; + static String updateRiderLogLive = + "https://jupiter.doormile.app/$mainRoute/api/v1/partners/updateriderlog"; + + //Get Rider Count - v1 + static String getRiderCountDev = + "https://jupiter.doormile.app/$mainDev/api/v1/partners/getridercount"; + static String getRiderCountLive = + "https://jupiter.doormile.app/$mainRoute/api/v1/partners/getridercount"; + + //Create Break Rider Log - v2 + static String createBreakRiderLogDev = + "https://queue.workolik.com/live/api/v2/partners/createbreaklog"; + static String createBreakRiderLogLive = + "https://queue.workolik.com/live/api/v2/partners/createbreaklog"; + + //Update Break Rider Log - v2 + static String updateBreakRiderLogDev = + "https://queue.workolik.com/live/api/v2/partners/updatebreaklog"; + static String updateBreakRiderLogLive = + "https://queue.workolik.com/live/api/v2/partners/updatebreaklog"; + + //Create Collection Log - v2 + static String createPickupLogDev = + "https://queue.workolik.com/live/api/v2/pickups/createpickuplog"; + static String createPickupLogLive = + "https://queue.workolik.com/live/api/v2/pickups/createpickuplog"; + + //Summary API - v2 + static String summaryApiLive = + 'https://jupiter.doormile.app/$mainRoute/api/v2/partners'; + + //Summary Rider Weekly KMs - v1 + static String summaryriderkmLive = + 'https://jupiter.doormile.app/$mainRoute/api/v1/partners'; +} diff --git a/lib/views/helpers/constants/app_theme.dart b/lib/views/helpers/constants/app_theme.dart new file mode 100644 index 0000000..1fff3ad --- /dev/null +++ b/lib/views/helpers/constants/app_theme.dart @@ -0,0 +1,206 @@ +import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder; +import 'package:flutter/material.dart'; +import 'package:flutter/services.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'; + +/// Centralized app theme (single source of truth for colors, type, and +/// component styling). Screens should lean on this instead of re-specifying +/// `ColorConstants.*` + `FontConstants.fontFamily` on every widget. +/// +/// Everything below maps to the existing red/maroon brand via [ColorConstants] +/// — no colors changed, just consolidated and extended with component themes +/// (buttons, inputs, snackbars, sheets, dividers) so inline styling can be +/// retired incrementally. +class AppTheme { + AppTheme._(); + + static const Color _primary = ColorConstants.primary; + + /// The app's one theme. + static ThemeData get light => _build(); + + static ThemeData _build() => ThemeData( + useMaterial3: true, + brightness: Brightness.light, + // The floor for page motion. Named routes and any route pushed without + // going through `openScreen`/`openSheet` still slide horizontally with + // the parallax and edge-swipe-back, instead of Android's default + // fade-through zoom — which, on the mid-range phones riders carry, + // reads as the new screen simply appearing. + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: CupertinoPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + }, + ), + fontFamily: FontConstants.fontFamily, + fontFamilyFallback: const ['Roboto', 'sans-serif'], + textTheme: ThemeData.light().textTheme.apply( + fontFamily: FontConstants.fontFamily, + ), + primaryTextTheme: ThemeData.light().primaryTextTheme.apply( + fontFamily: FontConstants.fontFamily, + ), + colorScheme: const ColorScheme( + brightness: Brightness.light, + primary: ColorConstants.primary, + onPrimary: ColorConstants.onPrimary, + primaryContainer: ColorConstants.primaryContainer, + onPrimaryContainer: ColorConstants.onPrimaryContainer, + secondary: ColorConstants.secondary, + onSecondary: ColorConstants.onSecondary, + secondaryContainer: ColorConstants.secondaryContainer, + onSecondaryContainer: ColorConstants.onSurface, + tertiary: ColorConstants.tertiary, + onTertiary: ColorConstants.onTertiary, + tertiaryContainer: ColorConstants.tertiaryContainer, + onTertiaryContainer: ColorConstants.onPrimary, + error: ColorConstants.error, + onError: ColorConstants.onError, + errorContainer: ColorConstants.errorContainer, + onErrorContainer: ColorConstants.onSurface, + surface: ColorConstants.surface, + onSurface: ColorConstants.onSurface, + surfaceContainerHighest: ColorConstants.surfaceContainerHighest, + outline: ColorConstants.outline, + outlineVariant: ColorConstants.outlineVariant, + ), + appBarTheme: const AppBarTheme( + backgroundColor: ColorConstants.pureSurface, + elevation: 0, + foregroundColor: ColorConstants.onSurface, + systemOverlayStyle: SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + ), + ), + scaffoldBackgroundColor: ColorConstants.surface, + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: _primary, + linearTrackColor: ColorConstants.surfaceContainerHigh, + ), + dividerTheme: const DividerThemeData( + color: ColorConstants.dividerColor, + thickness: 1, + space: 1, + ), + cardTheme: CardThemeData( + color: Colors.white, + surfaceTintColor: Colors.transparent, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + side: const BorderSide(color: ColorConstants.outlineVariant, width: 1), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: _primary, + foregroundColor: Colors.white, + disabledBackgroundColor: _primary.withValues(alpha: 0.35), + disabledForegroundColor: Colors.white.withValues(alpha: 0.9), + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 18), + minimumSize: const Size(double.infinity, ButtonSizes.primary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: _primary, + side: const BorderSide(color: _primary, width: 1.4), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + // ── Why this line exists ── + // + // `ElevatedButton` above pins its height to 56. `OutlinedButton` + // used to pin nothing, so it fell back to Material's default 40dp + // floor and sized itself from its own padding instead. That is + // invisible until the two appear together — which is exactly where + // they always appear, as the Cancel/Confirm pair of a dialog. Each + // of those rows overrode padding on both buttons and assumed that + // settled the height; it did not, because `styleFrom` only replaces + // the keys you hand it. Cancel came out 44–49 and Confirm came out + // 56, side by side, in three separate dialogs. + // + // Matching the height here means a plain `OutlinedButton` and a + // plain `ElevatedButton` agree by default, and nobody has to know + // about the merge order to get a straight row. + // + // Deliberately `64` wide and not `double.infinity`: outlined + // buttons are the ones that share a row or hug their label, so + // forcing full width would be wrong more often than right. A + // button that must be shorter than 56 has to say so explicitly + // with `minimumSize: Size.zero` — see [SecondaryButton]. + minimumSize: const Size(64, ButtonSizes.primary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: _primary, + textStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: ColorConstants.onSurface, + contentTextStyle: const TextStyle( + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + ), + bottomSheetTheme: BottomSheetThemeData( + backgroundColor: ColorConstants.pureSurface, + surfaceTintColor: Colors.transparent, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: ColorConstants.pureSurface, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderSide: const BorderSide(color: ColorConstants.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderSide: const BorderSide(color: ColorConstants.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + borderSide: const BorderSide(color: _primary, width: 2), + ), + labelStyle: const TextStyle( + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ); +} diff --git a/lib/views/helpers/constants/design_constants.dart b/lib/views/helpers/constants/design_constants.dart new file mode 100644 index 0000000..33c5481 --- /dev/null +++ b/lib/views/helpers/constants/design_constants.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; + +/// Miler Professional Design System Constants +/// Centralized design tokens for spacing, radius, shadows, and layout +/// ───────────────────────────────────────────────────────────────────────── +/// SHARED RADII AND ELEVATION +/// +/// ── Why this used to be much bigger ── +/// +/// This class shipped 43 members: a nine-step spacing scale, eleven `EdgeInsets` +/// presets, six radii, five shadows, a `cardDecoration` helper and three named +/// border radii. An audit found **six of them used, 17 times in total.** The +/// spacing scale had never been referenced once; every screen writes `16.w` or +/// `12.h` inline, which is both more readable at the call site and correct for +/// this app, because those need ScreenUtil's per-device scaling and a `const +/// double` cannot have it. +/// +/// The 37 dead members were not harmless. A token nobody uses still has to be +/// read, still looks authoritative, and is the reason someone reaches for +/// `Colors.grey.shade100` instead — a design system that is mostly aspirational +/// teaches people to ignore it. So the unused members are gone and what is left +/// is only what the app actually reaches for. +/// +/// The audit metric that flagged this file was itself misleading, which is worth +/// recording: 13 files import `design_constants.dart` and only 5 mention +/// `DesignConstants`. The other 8 import it for [ButtonSizes], which lives in +/// this same file and is used 72 times. Colocating a thriving class with a dead +/// one hid both facts. +/// +/// **Adding to this class:** don't, until a second call site needs the value. +/// Two uses make a token; one use is a local constant. +/// ───────────────────────────────────────────────────────────────────────── +class DesignConstants { + DesignConstants._(); + + // ── Motion ──────────────────────────────────────────────────────────── + // + // The app was using 35 distinct durations. No shared vocabulary means every + // transition is a fresh guess, and some of the guesses sat directly in the + // rider's path between two stops — an 800ms entrance and several 500ms + // cross-fades on screens he passes through 40 times a shift. + // + // Motion on a driver app is a tax paid all day. It earns its place only when + // it explains a state change; anything else is time spent standing still. + + /// Press and release. Fast enough to feel like the surface responded rather + /// than animated. + static const Duration motionPress = Duration(milliseconds: 120); + + /// A state change in place: a chip selecting, a tick landing, a row opening. + static const Duration motionState = Duration(milliseconds: 200); + + /// A page or a sheet arriving. The ceiling for anything on the critical path. + static const Duration motionPage = Duration(milliseconds: 300); + + /// Celebration, and only celebration — once per completed route, never on a + /// screen the rider is trying to get through. + static const Duration motionCelebrate = Duration(milliseconds: 600); + + // ── Border radius ───────────────────────────────────────────────────── + // + // ── Three, because 26 communicated nothing ── + // + // An audit counted 26 distinct radii in use against the four declared here, + // and found the most common value in the app was 14 — `ButtonSizes.radius`, + // a *button* token, being applied to cards and sheets. Radius is one of the + // strongest cues that two things belong to the same family; at 26 values it + // says nothing, and two elements side by side at 12 and 14 read as a + // rendering bug rather than a decision. + // + // `radius2xl` (20) is retired: it was the third-most-used value and had no + // job the container radius could not do. + + /// **Inner** — inputs, chips, small tiles, panels *inside* a container. + static const double radiusLg = 12.0; + + /// **Container** — cards, sheets, and anything that is a surface in its own + /// right. Buttons use this too, so a button and the card it sits on agree. + static const double radiusXl = 16.0; + + /// **Pill** — fully rounded: pills, circular tracks, avatars. + static const double radiusFull = 999.0; + + // ── Elevation ───────────────────────────────────────────────────────── + // + // Only two survive, and both are used sparingly. Most cards in the app are + // now borderless fills with no shadow at all — see + // `ColorConstants.cardSurface` — so elevation is the exception, not the + // default it once was. + + /// A card lifted just off the page. + static const List shadowSm = [ + BoxShadow(color: Color(0x1A000000), blurRadius: 4, offset: Offset(0, 2)), + ]; + + /// Something floating over content: a menu, a dragged element. + static const List shadowLg = [ + BoxShadow(color: Color(0x2A000000), blurRadius: 12, offset: Offset(0, 8)), + ]; +} + +/// ───────────────────────────────────────────────────────────────────────── +/// BUTTON SIZING — one scale for the whole app. +/// +/// Buttons had drifted to a dozen different heights (44, 48, 52, 54, 56, 58) +/// across the pickup flow, Home, auth and the sheets. On a screen the rider +/// uses 30–50 times a day that reads as sloppiness, and worse, it breaks the +/// muscle memory that lets him hit a control without looking: if "the green +/// one at the bottom" is a different size on every screen, he has to aim. +/// +/// Three sizes, and only three. Every button in the app must pick one. +/// ───────────────────────────────────────────────────────────────────────── +class ButtonSizes { + ButtonSizes._(); + + /// **Primary** — the one action a screen exists for. Full-width CTAs: + /// Accept Trip, Start Pickup, Confirm, Navigate. + static const double primary = 56.0; + + /// **Secondary** — supporting actions that sit beside or below a primary: + /// Reject, Skip, Details, filter chips with a tap target. + static const double secondary = 48.0; + + /// **Compact** — dense rows where several actions share a line, e.g. the + /// per-stop Accept/Reject pair inside a trip card. Still above the 44dp + /// accessibility floor. + static const double compact = 44.0; + + /// Square icon-only buttons (call, overflow) match [primary] so they line up + /// with the CTA they sit next to. + static const double icon = 56.0; + + /// Corner radius, shared by every button size so they read as one family. + /// + /// 14 → 16 so it matches [DesignConstants.radiusXl]. At 14 a button sitting + /// on a 16pt card had a visibly tighter corner than the card holding it, + /// which is the kind of two-point difference nobody can name and everybody + /// sees. + static const double radius = DesignConstants.radiusXl; + + /// Absolute floor for any tappable target, per accessibility guidance. + static const double minTapTarget = 44.0; +} + +/// ───────────────────────────────────────────────────────────────────────── +/// DESIGN TRIAL — full addresses on stop cards +/// +/// Turn on to see every stop card carrying its **complete** address rather than +/// the trimmed one, over [kFullAddressMaxLines] lines instead of two. +/// +/// Two separate things are suppressed normally, and both are lifted together +/// here or the trial would not show what it claims to: +/// +/// • **Home shortens the text.** `Trip.shortAddress` strips the tail every stop +/// on the route shares — the city, the state, the pincode — because repeating +/// "Coimbatore, Tamil Nadu 641006" on all six stops tells the rider nothing +/// about which one he is looking at. With the trial on, the raw address is +/// used instead. Bookings already shows the raw value. +/// +/// • **Both screens cap the lines.** Two, so a card's height does not depend on +/// how verbose a customer was — a list of cards that step unevenly down the +/// screen is harder to scan and fits fewer stops. +/// +/// Both caps exist for measured reasons, so this is a switch rather than a +/// deletion. **Set to `false` to restore the shipped behaviour.** +const bool kFullAddressTrial = true; + +/// Line cap while [kFullAddressTrial] is on. Four fits a full Indian address at +/// 13sp in a stop card's column without letting one pathological record run the +/// card off the screen. +const int kFullAddressMaxLines = 4; diff --git a/lib/views/helpers/constants/miler_type.dart b/lib/views/helpers/constants/miler_type.dart new file mode 100644 index 0000000..da46820 --- /dev/null +++ b/lib/views/helpers/constants/miler_type.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.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'; + +/// ───────────────────────────────────────────────────────────────────────── +/// THE TYPE SCALE +/// +/// ── Why this exists ── +/// +/// An audit counted **51 distinct font sizes** across the app and **448 sites +/// repeating `fontFamily: FontConstants.fontFamily` by hand**. Sizes in use ran +/// 9, 9.5, 10, 10.5, 11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15, 16, 16.5, 17, +/// 18, 19, 20, 21, 22, 23, 24, 26, 27, 28, 30, 46. +/// +/// That is not carelessness. There was simply nothing to look up: every screen +/// had to invent its own typography, so screens drifted the moment they were +/// written and a reviewer had nothing to compare a pull request against. It is +/// why the page title is 23sp on three tabs, 22sp on two flow screens and 21sp +/// on another. +/// +/// ── Why not `Theme.of(context).textTheme` ── +/// +/// The same reason [ColorConstants] is static: the app reads type on hundreds +/// of call sites that have no `BuildContext` anywhere near them, and `AppTheme` +/// already proves the point — it builds a full `TextTheme` that **nothing in +/// the app reads**. A scale nobody can reach is not a scale. +/// +/// So these are plain static getters, in the shape the app already reaches for. +/// +/// ── The eight roles ── +/// +/// | role | size | where | +/// |---------------|------|-------| +/// | [hero] | 28 | the one thing a screen is about — the next stop's name | +/// | [pageTitle] | 22 | app bar titles, sheet titles | +/// | [cardTitle] | 19 | a stop name in a list, a section heading | +/// | [body] | 16 | addresses, running copy, list-row titles | +/// | [caption] | 15 | supporting copy under a title | +/// | [label] | 13 | metadata, timestamps, chip text | +/// | [micro] | 12.5 | the smallest readable size in the app | +/// | [eyebrow] | 11 | letterspaced uppercase group names | +/// +/// [figure] and [buttonLabel] are roles rather than sizes — a figure is a +/// number read at a glance and takes the size the context demands, a button +/// label is fixed by [ButtonSizes]. +/// +/// ── Rules ── +/// +/// * **Same role = same size.** Do not add a size; add a role, and only when a +/// second screen needs it. +/// * **No half points** unless the half point is doing real work. `12.5` earns +/// its place as the floor; `13.5`, `14.5` and `16.5` did not and are gone. +/// * Every style already carries the family, so **no call site should name it**. +/// * Colour is a separate axis. Pass `.on(...)` to recolour, or use the named +/// helpers, so a role never hardcodes a token. +/// ───────────────────────────────────────────────────────────────────────── +class MilerType { + MilerType._(); + + static const String _family = FontConstants.fontFamily; + + /// The one thing a screen is about. One per screen, or none. + static TextStyle get hero => TextStyle( + fontSize: 28.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.9, + height: 1.08, + color: ColorConstants.slateText, + fontFamily: _family, + ); + + /// App bar titles and sheet headings. + static TextStyle get pageTitle => TextStyle( + fontSize: 22.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.6, + height: 1.15, + color: ColorConstants.slateText, + fontFamily: _family, + ); + + /// A stop name in a list, a section heading inside a screen. + static TextStyle get cardTitle => TextStyle( + fontSize: 19.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.4, + height: 1.2, + color: ColorConstants.slateText, + fontFamily: _family, + ); + + /// Addresses, running copy, the title line of a compact row. + static TextStyle get body => TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + height: 1.35, + color: ColorConstants.slateText, + fontFamily: _family, + ); + + /// Supporting copy under a title. Grey by default — this is the role that + /// explains, and explanation is secondary to the thing being explained. + static TextStyle get caption => TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w500, + height: 1.4, + color: ColorConstants.secondaryText, + fontFamily: _family, + ); + + /// Metadata, timestamps, chip text, the second line of a row. + static TextStyle get label => TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w600, + height: 1.35, + color: ColorConstants.secondaryText, + fontFamily: _family, + ); + + /// The floor. Nothing in this app may be smaller than this and still be + /// expected to be read outdoors. + static TextStyle get micro => TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, + height: 1.3, + color: ColorConstants.secondaryText, + fontFamily: _family, + ); + + /// Letterspaced uppercase group names. The quietest way to name a group, and + /// the reason most groups in this app need no outline. + static TextStyle get eyebrow => TextStyle( + fontSize: 11.sp, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + height: 1.3, + color: ColorConstants.secondaryText, + fontFamily: _family, + ); + + /// [pageTitle] without ScreenUtil. + /// + /// App bars are built eagerly as a `PreferredSizeWidget` argument, sometimes + /// before `ScreenUtilInit` has run — and `toolbarHeight` is a fixed logical + /// value anyway, so a title that scaled with the device inside a bar that did + /// not would only find new ways to clip. This is the same role at the same + /// number, resolved without `.sp`. + static const TextStyle pageTitleFixed = TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + letterSpacing: -0.6, + height: 1.15, + color: ColorConstants.slateText, + fontFamily: _family, + ); + + /// A number read at a glance: a cash amount, a distance, a duration. + /// + /// Sized by its context rather than fixed — a figure in a stat column is not + /// the same size as one in a hero — so this takes the size and supplies only + /// what makes a figure a figure: heavy, tight, and tabular so a column of + /// them lines up. + static TextStyle figure(double size, {Color? color}) => TextStyle( + fontSize: size.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + height: 1.1, + fontFeatures: const [FontFeature.tabularFigures()], + color: color ?? ColorConstants.slateText, + fontFamily: _family, + ); + + /// 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, + letterSpacing: -0.1, + color: ColorConstants.onAccent, + fontFamily: _family, + ); +} + +/// Recolour and reweight a role without leaving the scale. +/// +/// `MilerType.body.on(ColorConstants.secondaryText)` keeps the size, family, +/// tracking and line height of the role and changes only what is asked for — +/// which is the whole point of having roles. +extension MilerTypeTint on TextStyle { + TextStyle on(Color color) => copyWith(color: color); + + TextStyle get bold => copyWith(fontWeight: FontWeight.w800); + + TextStyle get semibold => copyWith(fontWeight: FontWeight.w700); +} diff --git a/lib/views/helpers/constants/mqtt_constants.dart b/lib/views/helpers/constants/mqtt_constants.dart new file mode 100644 index 0000000..2da9a8b --- /dev/null +++ b/lib/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/views/helpers/widgets/app_widgets.dart b/lib/views/helpers/widgets/app_widgets.dart new file mode 100644 index 0000000..8f50338 --- /dev/null +++ b/lib/views/helpers/widgets/app_widgets.dart @@ -0,0 +1,1687 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:shimmer/shimmer.dart'; + +import 'miler_app_bar.dart' show milerGlassSurface; +import 'package:miler/views/helpers/constants/miler_type.dart'; + +import '../constants/Colorconstants.dart'; +import '../constants/design_constants.dart'; +import '../constants/Font_constant.dart'; + +/// ─────────────────────────────────────────────────────────────────────────── +/// Miler shared design kit +/// +/// Reusable building blocks that mirror the Doormile customer app's clean +/// Material-3 look, backed by [ColorConstants], [DesignConstants] and +/// [FontConstants]. Use these instead of hand-rolling cards/buttons/chips so +/// every screen stays visually consistent. +/// ─────────────────────────────────────────────────────────────────────────── + +/// White rounded card with the design-system shadow. +class AppCard extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry padding; + final VoidCallback? onTap; + final Color? _colorOverride; + + /// Resolved rather than defaulted. + /// + /// The override is stored and the token is read at paint time, so an + /// un-overridden widget always draws the current token. + Color get color => _colorOverride ?? ColorConstants.surfaceContainerLowest; + final BorderRadius? radius; + final Border? border; + final bool elevated; + + const AppCard({ + super.key, + required this.child, + this.padding = const EdgeInsets.all(16), + this.onTap, + Color? color, + this.radius, + this.border, + this.elevated = true, + }) : _colorOverride = color; + + @override + Widget build(BuildContext context) { + final br = radius ?? BorderRadius.circular(DesignConstants.radiusXl); + return Material( + color: color, + borderRadius: br, + child: InkWell( + onTap: onTap, + borderRadius: br, + child: Ink( + decoration: BoxDecoration( + color: color, + borderRadius: br, + border: + border ?? + Border.all(color: ColorConstants.dividerColor, width: 1), + boxShadow: elevated ? DesignConstants.shadowSm : null, + ), + child: Padding(padding: padding, child: child), + ), + ), + ); + } +} + +/// Primary filled action button (brand red), [ButtonSizes.primary] tall, +/// [ButtonSizes.radius] corners — the same metrics as [MilerButton], which is +/// the newer widget and the one to reach for in new code. +/// +/// These two disagreed until now: this one rounded at `radiusLg` (12) while +/// [MilerButton] rounded at `ButtonSizes.radius` (14), so the app's two +/// "official" primary buttons were visibly different objects — and both are +/// live, this one in Help Center, Edit Profile and Saved Addresses. +class PrimaryButton extends StatelessWidget { + final String label; + final VoidCallback? onPressed; + final IconData? icon; + final bool loading; + final Color? _colorOverride; + + /// Resolved rather than defaulted. + /// + /// The override is stored and the token is read at paint time, so an + /// un-overridden widget always draws the current token. + Color get color => _colorOverride ?? ColorConstants.primary; + final Color foreground; + final double height; + + const PrimaryButton({ + super.key, + required this.label, + required this.onPressed, + this.icon, + this.loading = false, + Color? color, + this.foreground = Colors.white, + this.height = ButtonSizes.primary, + }) : _colorOverride = color; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + height: height, + child: ElevatedButton( + onPressed: loading ? null : onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: color, + foregroundColor: foreground, + disabledBackgroundColor: color.withValues(alpha: 0.5), + disabledForegroundColor: foreground.withValues(alpha: 0.8), + elevation: 0, + minimumSize: Size(double.infinity, height), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + ), + child: loading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: foreground, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 20), + const SizedBox(width: 8), + ], + Flexible( + child: Text( + label, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Tonal secondary button for minor actions. +class SecondaryButton extends StatelessWidget { + final String label; + final VoidCallback? onPressed; + final IconData? icon; + final Color? color; + final double height; + + const SecondaryButton({ + super.key, + required this.label, + this.onPressed, + this.icon, + this.color, + this.height = ButtonSizes.secondary, + }); + + @override + Widget build(BuildContext context) { + final c = color ?? ColorConstants.primary; + return SizedBox( + height: height, + child: OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + backgroundColor: c.withValues(alpha: 0.08), + foregroundColor: c, + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ButtonSizes.radius), + ), + padding: const EdgeInsets.symmetric(horizontal: 18), + // The theme floors every OutlinedButton at [ButtonSizes.primary] so + // dialog pairs line up. This one is deliberately a secondary, lives + // in a 48dp box, and would be clipped by that floor — so it opts out + // and lets the enclosing SizedBox be the only thing setting height. + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 18, color: c), + const SizedBox(width: 8), + ], + Text( + label, + style: TextStyle( + color: c, + fontSize: 14, + fontWeight: FontWeight.w700, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ); + } +} + +/// Pill-shaped status / category chip. +class StatusChip extends StatelessWidget { + final String label; + final Color color; + final Color bg; + final IconData? icon; + + const StatusChip({ + super.key, + required this.label, + required this.color, + required this.bg, + this.icon, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + ], + Text( + label, + style: TextStyle( + color: color, + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } +} + +/// A square/circular icon avatar with a tinted background. +class IconBadge extends StatelessWidget { + final IconData icon; + final Color bg; + final Color color; + final double size; + final double iconSize; + final bool circle; + + const IconBadge({ + super.key, + required this.icon, + required this.bg, + required this.color, + this.size = 44, + this.iconSize = 22, + this.circle = false, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular( + circle ? size : DesignConstants.radiusLg, + ), + ), + child: Icon(icon, color: color, size: iconSize), + ); + } +} + +/// Title row with an optional trailing text action. +class SectionHeader extends StatelessWidget { + final String title; + final String? action; + final VoidCallback? onAction; + + const SectionHeader({ + super.key, + required this.title, + this.action, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text( + title, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + color: ColorConstants.onSurface, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + if (action != null) + GestureDetector( + onTap: onAction, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + action!, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: ColorConstants.primary, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ], + ); + } +} + +/// Thin rounded progress bar (0..1). +class AppProgressBar extends StatelessWidget { + final double value; + final Color? color; + final double height; + + const AppProgressBar({ + super.key, + required this.value, + this.color, + this.height = 8, + }); + + @override + Widget build(BuildContext context) { + return Container( + height: height, + decoration: BoxDecoration( + color: ColorConstants.surfaceContainerHigh, + borderRadius: BorderRadius.circular(height / 2), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(height / 2), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: value.clamp(0.0, 1.0), + child: Container(color: color ?? ColorConstants.primary), + ), + ), + ); + } +} + +/// Soft info/reassurance banner (tinted background + leading icon). +class InfoBanner extends StatelessWidget { + final String text; + final IconData icon; + final Color? _colorOverride; + + /// Resolved rather than defaulted. + /// + /// The override is stored and the token is read at paint time, so an + /// un-overridden widget always draws the current token. + Color get color => _colorOverride ?? ColorConstants.tertiary; + + const InfoBanner({ + super.key, + required this.text, + this.icon = Icons.info_outline_rounded, + Color? color, + }) : _colorOverride = color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + border: Border.all(color: color.withValues(alpha: 0.16)), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 22), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: TextStyle( + color: color, + fontSize: 13, + height: 1.35, + fontWeight: FontWeight.w500, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ); + } +} + +/// Full-screen empty / zero-state placeholder. +/// +/// Matches the app's gold-standard "You're all caught up" look: a soft layered +/// brand-tinted circle behind an icon, a bold heading and a helpful subtitle, +/// with an optional call-to-action. Use this instead of hand-rolling a grey +/// icon + text so every empty screen reads as one system. +class MilerEmptyState extends StatelessWidget { + final IconData icon; + final String title; + final String? message; + + /// A Lottie animation shown *instead* of the tinted icon halo. + /// + /// Bookings and Activity each hand-rolled a Lottie empty state — different + /// headline size (18 vs 17), different body size (14 vs 12.5), different + /// animation size (160 vs 190) and different horizontal padding (48 vs 44). + /// Empty is the state a new rider sees most in his first week; three + /// near-identical-but-not treatments make the app feel unfinished at exactly + /// the moment he is deciding whether to trust it. + /// + /// The copy stays screen-specific — an empty day and an unworked trip are + /// different sentences — but the chrome is one thing now. + final Widget? illustration; + + /// 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. + final Widget? footer; + + /// Accent used for the icon and its tinted backdrop. Defaults to the brand. + final Color? _accentOverride; + + /// Resolved rather than defaulted. + /// + /// The override is stored and the token is read at paint time, so an + /// un-overridden widget always draws the current token. + Color get accent => _accentOverride ?? ColorConstants.primary; + + /// Optional call-to-action button rendered under the message. + final String? actionLabel; + final VoidCallback? onAction; + + const MilerEmptyState({ + super.key, + required this.icon, + required this.title, + this.message, + Color? accent, + this.actionLabel, + this.onAction, + this.illustration, + this.footer, + }) : _accentOverride = accent; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // An illustration replaces the halo when one is supplied; the + // 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) + 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, + ), + ), + 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), + ), + ), + ], + ), + const SizedBox(height: 24), + Text( + title, + textAlign: TextAlign.center, + style: MilerType.cardTitle, + ), + if (message != null) ...[ + const SizedBox(height: 8), + Text( + message!, + textAlign: TextAlign.center, + style: MilerType.caption, + ), + ], + if (actionLabel != null) ...[ + const SizedBox(height: 22), + // Filled, not outlined. On an empty state the one action present + // should look like one — outlined-on-white read as a disabled + // control on an already-pale screen. + MilerButton( + label: actionLabel!, + color: accent, + height: ButtonSizes.compact, + expand: false, + onPressed: onAction, + ), + ], + if (footer != null) ...[const SizedBox(height: 16), footer!], + ], + ), + ), + ); + } +} + +/// Clean, minimal loader — a thin brand-red spinner, optionally with a label. +/// Mirrors Doormile's understated `CircularProgressIndicator(strokeWidth: 2.4)`. +class AppLoader extends StatelessWidget { + final double size; + final Color? color; + final String? label; + + const AppLoader({super.key, this.size = 26, this.color, this.label}); + + @override + Widget build(BuildContext context) { + final c = color ?? ColorConstants.primary; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: size, + height: size, + child: CircularProgressIndicator(strokeWidth: 2.6, color: c), + ), + if (label != null) ...[ + const SizedBox(height: 14), + Text( + label!, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: ColorConstants.onSurfaceVariant, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ], + ); + } +} + +/// Centered loader inside a white rounded card — for blocking dialogs. +class AppLoaderCard extends StatelessWidget { + final String? label; + const AppLoaderCard({super.key, this.label}); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + boxShadow: DesignConstants.shadowLg, + ), + child: AppLoader(label: label), + ), + ); + } +} + +/// Clean back-arrow app bar shared across detail screens. +/// ───────────────────────────────────────────────────────────────────────── +/// THE APP BAR FOR EVERY PUSHED PAGE +/// +/// One bar, used by every screen you can navigate *into*: Saved Addresses, +/// Notifications, Alert Sound, FAQ, Terms, Help, Support, Edit Profile. +/// +/// ── What it replaced ── +/// +/// Eight pushed pages had six different bars between them. Four were solid brand +/// red with white text; two were white with black text; one was grey; one was +/// white with a red title. The back arrow was `arrow_back`, `arrow_back_ios`, +/// `arrow_back_ios_new_rounded` or `arrow_back_rounded`, at 16, 20, 24 or 28, +/// in white, black or slate. Two shared helpers already existed and between them +/// were used by three pages. +/// +/// That is not a style question — it is a rider being unable to tell, from the +/// top of the screen, whether he is one level deep or somewhere else entirely. +/// +/// ── Why white with a red title, and not the reverse ── +/// +/// A saturated brand-red slab across the top of every page spends the loudest +/// colour available on the one element that never changes and never needs +/// acting on, and it puts white text on a mid-tone red, which is the weakest +/// contrast pairing in the palette. Inverting it gives the title the brand +/// colour where it costs nothing, keeps the surface neutral so the page's own +/// content leads, and matches the tab-level [MilerAppBar] — so moving from a tab +/// into a sub-page now reads as going deeper into one app rather than arriving +/// somewhere new. +/// ───────────────────────────────────────────────────────────────────────── +/// ── The × goes on its own line, above the title ── +/// +/// It used to share a row: `[×] Edit Profile`. Two problems with that, and the +/// second is the one that mattered. +/// +/// The obvious one is that the × ate the start of the line, so every title on +/// every pushed page began 52pt in from the margin — out of line with the page +/// content underneath it, which starts at the gutter like everything else in +/// the app. +/// +/// The real one is what a leading control *says*. Sitting to the left of a +/// title, at the same optical weight, it reads as part of the title's lockup — +/// the thing you look at, rather than a thing you press. Lifted onto its own +/// row it becomes unambiguously a control with the whole row to itself, and the +/// title below is left free to be the page's heading, aligned to the same +/// margin as the content it heads. It is the shape Uber, Revolut and iOS large +/// titles all settle on for a leaf page, for the same reason. +/// +/// Heights are generous rather than tight because this bar has no +/// `BuildContext` to read a text scaler from, and `toolbarHeight` is fixed — +/// the Account pages are covered at 1.0×–2.0× by `profile_pages_test.dart`. +const double _kTopBarHeight = 104; + +/// The × on the control row. +/// +/// Hand-rolled rather than an `IconButton`, for one reason: alignment. The +/// whole point of stacking the control above the heading is that the two share +/// a left margin, and `IconButton` will not do that. It ignores a tight +/// `BoxConstraints` on the minor axis (it came out 48 wide against a requested +/// 44) and insets its glyph a further 2pt inside whatever box it settles on, so +/// the × landed 2pt right of the title no matter what `alignment` was passed. +/// +/// A `SizedBox` + `Align` is exact, gives a true 44pt target rather than an +/// approximate one, and is less code than the overrides it replaces. +class _TopBarClose extends StatelessWidget { + final IconData icon; + final VoidCallback onTap; + final String semanticLabel; + + const _TopBarClose({ + required this.icon, + required this.onTap, + this.semanticLabel = 'Back', + }); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: semanticLabel, + child: InkResponse( + onTap: onTap, + radius: ButtonSizes.minTapTarget / 2 + 4, + // 44 → 48. The 44pt floor is the accessibility *minimum*; 48 is the + // Material target and what a thumb on a moving bike actually needs. + // The glyph stays 24 — the hit area grows, the icon does not. + child: SizedBox( + width: 48, + height: 48, + child: Align( + alignment: Alignment.centerLeft, + child: Icon(icon, size: 24, color: ColorConstants.slateText), + ), + ), + ), + ); + } +} + +/// ── One bar for every pushed screen ── +/// +/// An audit found **five** app-bar implementations across the app: this one at +/// 104pt with a maroon 22sp title, [MilerAppBar] at 76pt with a slate 23sp +/// title, Home's hand-rolled header, a bespoke 88pt bar on the verify and +/// payment screens, and raw `AppBar` on two more. Three title sizes, three back +/// icons, two title colours. The heading physically jumped and changed colour +/// as the rider moved between a tab and a pushed page. +/// +/// `MilerPageBar` is now the only bar for a pushed screen. [MilerAppBar] stays +/// for the four tabs, and Home keeps its hand-rolled header because it holds +/// the avatar and the duty switch — but all three now share one title size, one +/// title colour and one inset, so nothing moves vertically between them. +/// +/// ── What changed from `appTopBar` ── +/// +/// * **Title colour: maroon → slate.** It was the only heading in the app in +/// the brand colour, which read as a link rather than a title. +/// * **Back icon: × → `arrow_back_rounded`.** A cross promises dismissal; nine +/// of these pages are pushed screens you go *back* from. The cross survives +/// behind `isModal: true`, for sheets that genuinely dismiss. +/// * **Type from [MilerType.pageTitle]**, so it cannot drift again. +PreferredSizeWidget milerPageBar( + String title, { + List? actions, + VoidCallback? onBack, + + /// True for a screen that genuinely *dismisses* rather than pops — a sheet + /// presented modally. Only this gets the cross. + bool isModal = false, +}) { + final IconData backIcon = isModal + ? Icons.close_rounded + : Icons.arrow_back_rounded; + 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(), + surfaceTintColor: Colors.transparent, + foregroundColor: ColorConstants.slateText, + elevation: 0, + scrolledUnderElevation: 0, + // Everything lives in `title` so the control row and the heading can be + // stacked. Leaving `leading`/`actions` to the AppBar would centre them + // against the full bar height, i.e. beside the title again. + automaticallyImplyLeading: false, + toolbarHeight: _kTopBarHeight, + // The page gutter. Both the × and the heading start here, so they share one + // margin with the content below. + titleSpacing: 20, + title: Padding( + padding: const EdgeInsets.only(right: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasControlRow) + SizedBox( + height: 48, + child: Row( + children: [ + if (onBack != null) + _TopBarClose( + icon: backIcon, + onTap: onBack, + semanticLabel: isModal ? 'Close' : 'Back', + ), + const Spacer(), + if (actions != null) ...actions, + ], + ), + ), + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.pageTitleFixed, + ), + ], + ), + ), + ); +} + +/// ─────────────────────────────────────────────────────────────────────────── +/// Motion + loading primitives (the "Uber-smooth" layer) +/// ─────────────────────────────────────────────────────────────────────────── + +/// Fades + gently rises its [child] once, when first mounted. Pass a [delay] +/// (e.g. `index * 40ms`) to cascade a list. Because the animation only runs in +/// initState, a keyed [Reveal] plays exactly once per logical item — a rebuild +/// (poll refresh) does NOT replay it, so lists don't flicker under the finger. +class Reveal extends StatefulWidget { + final Widget child; + final Duration delay; + final Duration duration; + final double offsetY; + + const Reveal({ + super.key, + required this.child, + this.delay = Duration.zero, + this.duration = DesignConstants.motionPage, + this.offsetY = 16, + }); + + @override + State createState() => _RevealState(); +} + +class _RevealState extends State with SingleTickerProviderStateMixin { + late final AnimationController _c; + late final Animation _a; + + @override + void initState() { + super.initState(); + _c = AnimationController(vsync: this, duration: widget.duration); + _a = CurvedAnimation(parent: _c, curve: Curves.easeOutCubic); + if (widget.delay == Duration.zero) { + _c.forward(); + } else { + Future.delayed(widget.delay, () { + if (mounted) _c.forward(); + }); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _a, + builder: (_, child) => Opacity( + opacity: _a.value, + child: Transform.translate( + offset: Offset(0, widget.offsetY * (1 - _a.value)), + child: child, + ), + ), + child: widget.child, + ); + } +} + +/// Convenience: cap the stagger delay so long lists don't wait forever. +Duration staggerDelay(int index, {int stepMs = 45, int maxMs = 320}) => + Duration(milliseconds: (index * stepMs).clamp(0, maxMs)); + +/// 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 { + final Widget child; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final double scale; + + /// What a screen reader announces, and what it calls this control. + /// + /// PressScale wraps a `GestureDetector`, which is invisible to TalkBack — it + /// has no role, no label and no hint, so every card and custom row in the app + /// was announced as unlabelled text that happens to be there. Passing a + /// [semanticLabel] turns it into a button the reader can find, name and + /// activate. Leave it null only where the child is already fully labelled + /// text that says the same thing. + final String? semanticLabel; + + /// The consequence, in the reader's words — "opens the stop", "plays the + /// sound". Optional; the label alone is usually enough. + final String? semanticHint; + + /// Guarantees the control is at least [ButtonSizes.minTapTarget] *48* in + /// both axes without changing what is drawn. + /// + /// An audit found 18 bare `GestureDetector`s in the app with no tap-target + /// floor at all — a 32pt icon tapped one-handed on a moving bike is a miss. + /// The visible glyph keeps its size; only the hit area grows around it. + /// + /// Off by default so existing full-width rows are unaffected; turn it on for + /// icon-only controls. + final bool minTarget; + + const PressScale({ + super.key, + required this.child, + this.onTap, + this.onLongPress, + this.scale = 0.97, + this.semanticLabel, + this.semanticHint, + this.minTarget = false, + }); + + @override + State createState() => _PressScaleState(); +} + +class _PressScaleState extends State { + bool _pressed = false; + void _set(bool v) { + if (mounted) setState(() => _pressed = v); + } + + @override + Widget build(BuildContext context) { + final enabled = widget.onTap != null || widget.onLongPress != null; + final gesture = GestureDetector( + // Make the whole child area tappable, not just its painted pixels. + // Without this the default `deferToChild` leaves the gaps/whitespace in a + // row un-hittable, so taps land on dead space and nothing happens. + behavior: HitTestBehavior.opaque, + onTapDown: enabled ? (_) => _set(true) : null, + onTapUp: enabled ? (_) => _set(false) : null, + onTapCancel: enabled ? () => _set(false) : null, + onTap: widget.onTap, + onLongPress: widget.onLongPress, + child: AnimatedScale( + scale: _pressed ? widget.scale : 1.0, + duration: DesignConstants.motionPress, + curve: Curves.easeOut, + child: widget.child, + ), + ); + + final sized = widget.minTarget + ? ConstrainedBox( + constraints: const BoxConstraints(minWidth: 48, minHeight: 48), + child: Center(widthFactor: 1, heightFactor: 1, child: gesture), + ) + : gesture; + + if (widget.semanticLabel == null) return sized; + return Semantics( + button: enabled, + enabled: enabled, + label: widget.semanticLabel, + hint: widget.semanticHint, + // The children already read as text; without this the reader would + // announce the label and then every line inside it again. + excludeSemantics: true, + child: sized, + ); + } +} + +/// A single shimmering placeholder block. Compose these inside [MilerShimmer]. +class SkeletonBone extends StatelessWidget { + final double? width; + final double height; + final double radius; + const SkeletonBone({ + super.key, + this.width, + this.height = 14, + this.radius = 8, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(radius), + ), + ); + } +} + +/// Wraps skeleton bones in the brand shimmer sweep. Put a column of +/// [SkeletonBone]s (or [skeletonCard]s) inside. +class MilerShimmer extends StatelessWidget { + final Widget child; + const MilerShimmer({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return Shimmer.fromColors( + baseColor: ColorConstants.surfaceContainerHigh, + highlightColor: ColorConstants.surfaceContainerLow, + period: const Duration(milliseconds: 1300), + child: child, + ); + } +} + +/// One placeholder card shaped like a Miler list item (icon + two lines + chip). +Widget skeletonCard() { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 7), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: ColorConstants.surfaceContainerLowest, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Row( + children: [ + SkeletonBone(width: 40, height: 40, radius: 12), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBone(width: 140, height: 15), + SizedBox(height: 8), + SkeletonBone(width: 200, height: 12), + ], + ), + ), + SizedBox(width: 12), + SkeletonBone(width: 52, height: 24, radius: 12), + ], + ), + SizedBox(height: 18), + SkeletonBone(height: 44, radius: 12), + ], + ), + ); +} + +/// A full-list loading placeholder: [count] shimmering [skeletonCard]s. +class SkeletonList extends StatelessWidget { + final int count; + const SkeletonList({super.key, this.count = 4}); + + @override + Widget build(BuildContext context) { + return MilerShimmer( + child: Column( + children: [ + const SizedBox(height: 8), + for (int i = 0; i < count; i++) skeletonCard(), + ], + ), + ); + } +} + +/// Cross-fades between whatever it is given — a skeleton and the real content, +/// an empty state and a list, one filtered list and the next. +/// +/// Every loading screen in this app did the same thing: held a shimmer, then +/// replaced it with content in a single frame. The shimmer exists to say "this +/// is coming", and then the arrival — the one moment it was preparing the rider +/// for — was a cut. This fades the old out and lifts the new in over 280ms, so +/// the wait resolves instead of ending. +/// +/// The children must differ by [Key] (or runtime type) for the switch to be +/// seen; a `ValueKey('loading')` / `ValueKey('content')` pair is enough. +class SmoothSwap extends StatelessWidget { + final Widget child; + final Duration duration; + + const SmoothSwap({ + super.key, + required this.child, + this.duration = DesignConstants.motionState, + }); + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: duration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeIn, + // Top-aligned rather than the framework's centre default: mid-fade the + // two children are on screen together, and if a short skeleton is centred + // against a tall list every row appears to slide upward as it lands. + // + // Deliberately no `SizedBox.expand` around the incoming child — this is + // used inside slivers too, where forcing an infinite height would throw. + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: Alignment.topCenter, + children: [...previousChildren, if (currentChild != null) currentChild], + ), + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.02), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ), + child: child, + ); + } +} + +/// Holds an expensive child back until the route or sheet containing it has +/// finished animating in, showing [placeholder] in its place meanwhile. +/// +/// This exists for one specific offender: [GoogleMap]. A Google Map is an +/// Android *platform view* — creating it spins up a native surface, and that +/// work lands on the UI thread. Build it in the same frame the sheet starts +/// rising and it eats a couple of hundred milliseconds right in the middle of +/// the entrance, so the animation is technically running while the screen shows +/// nothing but its first and last frames. No curve fixes that; the frames are +/// simply never drawn. The tap looked broken because the most expensive widget +/// in the app was being constructed inside the animation. +/// +/// So: cheap widgets during the motion, the map a beat later, faded up over the +/// placeholder rather than swapped for it — a platform view under an +/// `AnimatedSwitcher` blends badly on Android, so the placeholder fades out on +/// top of it instead. +/// +/// [fallbackDelay] covers the case where there is no route animation to wait on +/// (already-settled route, or a widget used outside a route). +class AfterEntrance extends StatefulWidget { + final WidgetBuilder builder; + final Widget placeholder; + final Duration fallbackDelay; + final Duration fadeOut; + + const AfterEntrance({ + super.key, + required this.builder, + required this.placeholder, + this.fallbackDelay = const Duration(milliseconds: 450), + this.fadeOut = const Duration(milliseconds: 300), + }); + + @override + State createState() => _AfterEntranceState(); +} + +class _AfterEntranceState extends State { + bool _ready = false; + Animation? _watched; + Timer? _fallback; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_ready) return; + + final Animation? anim = ModalRoute.of(context)?.animation; + if (anim == null || anim.isCompleted) { + // Nothing to wait for — but still leave the current frame alone, so a + // rebuild during someone else's animation doesn't drag it down. + WidgetsBinding.instance.addPostFrameCallback((_) => _markReady()); + return; + } + if (!identical(anim, _watched)) { + _watched?.removeStatusListener(_onStatus); + _watched = anim..addStatusListener(_onStatus); + } + _fallback ??= Timer(widget.fallbackDelay, _markReady); + } + + void _onStatus(AnimationStatus status) { + if (status == AnimationStatus.completed) _markReady(); + } + + void _markReady() { + if (_ready || !mounted) return; + setState(() => _ready = true); + } + + @override + void dispose() { + _watched?.removeStatusListener(_onStatus); + _fallback?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + if (_ready) widget.builder(context), + IgnorePointer( + ignoring: _ready, + child: AnimatedOpacity( + opacity: _ready ? 0 : 1, + duration: widget.fadeOut, + curve: Curves.easeOut, + child: widget.placeholder, + ), + ), + ], + ); + } +} + +/// The stand-in a map wears while [AfterEntrance] holds it back: the right +/// shape and colour, with a pin, and deliberately no spinner — something that +/// spins for 300ms and vanishes reads as a stutter, not as progress. +class MapPlaceholder extends StatelessWidget { + final double? radius; + const MapPlaceholder({super.key, this.radius}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: ColorConstants.surfaceContainerHigh, + borderRadius: radius == null ? null : BorderRadius.circular(radius!), + ), + alignment: Alignment.center, + child: Icon( + Icons.place_rounded, + size: 30, + color: ColorConstants.onSurfaceVariant.withValues(alpha: 0.35), + ), + ); + } +} + +/// ───────────────────────────────────────────────────────────────────────── +/// MilerButton — the one button widget for the whole app. +/// +/// Use this instead of hand-rolling `ElevatedButton` / `OutlinedButton` with +/// a bespoke `SizedBox(height: …)`. It fixes the height to one of the three +/// [ButtonSizes], keeps the radius and typography identical everywhere, and +/// guarantees the 44dp tap-target floor. +/// +/// Three visual variants, mapped to meaning rather than looks: +/// • [MilerButtonVariant.filled] — the primary action. Solid, high contrast. +/// • [MilerButtonVariant.outlined] — a real alternative to the primary. +/// • [MilerButtonVariant.tonal] — a quieter action that is still safe. +/// ───────────────────────────────────────────────────────────────────────── +enum MilerButtonVariant { filled, outlined, tonal } + +class MilerButton extends StatelessWidget { + final String label; + final VoidCallback? onPressed; + final IconData? icon; + final bool loading; + + /// Semantic colour: brand maroon for pickup, green for go, red for danger. + /// Never pass a one-off hex — use a `ColorConstants` token. + final Color? _colorOverride; + + /// Resolved rather than defaulted. + /// + /// The override is stored and the token is read at paint time, so an + /// un-overridden widget always draws the current token. + Color get color => _colorOverride ?? ColorConstants.primary; + + final MilerButtonVariant variant; + + /// One of [ButtonSizes]. Defaults to [ButtonSizes.primary]. + final double height; + + /// Fills the available width. Off for buttons sharing a row. + final bool expand; + + const MilerButton({ + super.key, + required this.label, + required this.onPressed, + this.icon, + this.loading = false, + Color? color, + this.variant = MilerButtonVariant.filled, + this.height = ButtonSizes.primary, + this.expand = true, + }) : _colorOverride = color; + + bool get _enabled => onPressed != null && !loading; + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(ButtonSizes.radius); + + final Color bg = switch (variant) { + MilerButtonVariant.filled => color, + // The surface token, not a literal white — an outlined button is a + // *surface* with an edge drawn on it. + MilerButtonVariant.outlined => ColorConstants.pureSurface, + MilerButtonVariant.tonal => color.withValues(alpha: 0.12), + }; + // The fill under it is the brand colour, which is picked to carry white. + final Color fg = variant == MilerButtonVariant.filled + ? ColorConstants.onAccent + : color; + final Color? borderColor = switch (variant) { + MilerButtonVariant.filled => null, + MilerButtonVariant.outlined => color, + MilerButtonVariant.tonal => color.withValues(alpha: 0.35), + }; + + // Font tracks the height so a compact button doesn't shout like a primary. + final double fontSize = height >= ButtonSizes.primary + ? 15.5 + : height >= ButtonSizes.secondary + ? 14.0 + : 13.0; + final double iconSize = height >= ButtonSizes.primary ? 20 : 17; + + final child = loading + ? SizedBox( + width: iconSize, + height: iconSize, + child: CircularProgressIndicator(strokeWidth: 2.4, color: fg), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: iconSize, color: fg), + const SizedBox(width: 7), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w800, + letterSpacing: -0.1, + color: fg, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ); + + final button = Opacity( + // Disabled state is a uniform fade rather than three different greys, + // so "I can't press this" looks the same everywhere. + opacity: _enabled ? 1.0 : 0.45, + child: Material( + color: bg, + borderRadius: radius, + child: InkWell( + onTap: _enabled ? onPressed : null, + borderRadius: radius, + child: Container( + height: math.max(height, ButtonSizes.minTapTarget), + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + borderRadius: radius, + border: borderColor == null + ? null + : Border.all(color: borderColor, width: 1.5), + ), + child: child, + ), + ), + ), + ); + + return expand ? SizedBox(width: double.infinity, child: button) : button; + } +} + +/// Square icon-only button, sized from the same scale as [MilerButton] so it +/// lines up with whatever CTA it sits beside. +class MilerIconButton extends StatelessWidget { + final IconData icon; + final VoidCallback? onPressed; + final Color? _colorOverride; + + /// Resolved rather than defaulted. + /// + /// The override is stored and the token is read at paint time, so an + /// un-overridden widget always draws the current token. + Color get color => _colorOverride ?? ColorConstants.primary; + final double size; + final String semanticLabel; + final bool tonal; + + const MilerIconButton({ + super.key, + required this.icon, + required this.onPressed, + required this.semanticLabel, + Color? color, + this.size = ButtonSizes.icon, + this.tonal = true, + }) : _colorOverride = color; + + @override + Widget build(BuildContext context) { + final radius = BorderRadius.circular(ButtonSizes.radius); + return Semantics( + button: true, + label: semanticLabel, + child: Opacity( + opacity: onPressed == null ? 0.45 : 1.0, + child: Material( + color: tonal ? color.withValues(alpha: 0.12) : Colors.white, + borderRadius: radius, + child: InkWell( + onTap: onPressed, + borderRadius: radius, + child: Container( + width: math.max(size, ButtonSizes.minTapTarget), + height: math.max(size, ButtonSizes.minTapTarget), + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: radius, + border: Border.all( + color: color.withValues(alpha: 0.35), + width: 1.5, + ), + ), + child: Icon(icon, color: color, size: size >= 52 ? 22 : 19), + ), + ), + ), + ), + ); + } +} + +/// ─────────────────────────────────────────────────────────────────────────── +/// ONE LOOK FOR EVERY CODE BOX +/// +/// The rider types a code twice in his working life with this app: once at +/// login, and once at every customer's door. Those were two different controls +/// — different box size, radius, border weight, focus treatment, fill and type +/// size — because they are built on two different input models: the auth screen +/// uses one controller per box (which is what makes SMS autofill work), and the +/// delivery OTP draws boxes over a single hidden field (which is what makes +/// paste work). +/// +/// Both models are correct for their job, so this shares the *look* rather than +/// the widget. The rider meets one control; the two screens keep the input +/// behaviour each needs. +/// ─────────────────────────────────────────────────────────────────────────── +class MilerCodeBox { + MilerCodeBox._(); + + /// Gap between boxes. + static double gap(BuildContext _) => 8; + + /// The box itself, in whichever state it is in. + /// + /// [accent] lets the delivery OTP tint with its leg colour while login stays + /// on the brand — the one thing the two are allowed to differ on, because on + /// a verification screen the accent is what says which job is running. + static BoxDecoration decoration({ + required bool focused, + required bool filled, + required bool hasError, + required Color accent, + }) { + final Color border = hasError + ? ColorConstants.errorRed + : focused + ? accent + : filled + ? accent.withValues(alpha: 0.40) + : ColorConstants.borderStrong; + + return BoxDecoration( + color: filled && !hasError + ? accent.withValues(alpha: 0.08) + : ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + border: Border.all(color: border, width: focused || hasError ? 2 : 1.4), + ); + } + + /// The digit. + static TextStyle digit(Color accent, {bool hasError = false}) => + MilerType.figure( + 20, + color: hasError ? ColorConstants.errorRed : accent, + ); +} + +/// ─────────────────────────────────────────────────────────────────────────── +/// FEEDBACK — what the app says when something goes wrong +/// +/// The app had 84 `catch (_) {}` blocks. Every one of them is the same moment +/// for the rider: he taps, the request fails, and *nothing happens*. No error, +/// no spinner, no retry — the screen simply sits there. He taps again. Then he +/// decides the app is frozen, force-closes it, and calls the hub. Meanwhile the +/// failure was swallowed on his phone and nobody upstream ever hears about it. +/// +/// Silence is the worst possible response to a failure, because it is +/// indistinguishable from success that has not rendered yet. These are the two +/// things to say instead, and they are deliberately blunt: what failed, and +/// what he can do about it. +/// ─────────────────────────────────────────────────────────────────────────── +class AppFeedback { + AppFeedback._(); + + /// ── One shape, three tones ── + /// + /// An audit found **three** feedback systems in the app: 25 hand-rolled + /// `showSnackBar` calls with their own colours, radii, margins and durations, + /// 9 `Get.snackbar` overlays with a completely different visual language and + /// animation, and this. Error feedback is the app's contract with the rider + /// at the one moment something has gone wrong — three shapes meant "did that + /// work?" had three different answers depending on which screen he was on. + /// + /// Everything routes through here now. The tone changes the fill and the + /// glyph; nothing else varies. + /// + /// ── Clear of the floating nav bar ── + /// + /// The bottom nav is `Positioned` outside the `Scaffold` body, so a snackbar + /// at the default margin lands *underneath* it — the message the rider most + /// needs, hidden behind the tab bar. [_bottomInset] lifts every one of them + /// clear. + static void error( + BuildContext context, + String message, { + VoidCallback? onRetry, + Duration duration = const Duration(seconds: 4), + }) => _show( + context, + message, + icon: Icons.error_outline_rounded, + fill: ColorConstants.errorRed, + duration: duration, + onRetry: onRetry, + ); + + /// It worked. Used sparingly — a confirmation for every successful tap is + /// noise, and noise is how a rider learns to ignore the one that matters. + static void success(BuildContext context, String message) => _show( + context, + message, + icon: Icons.check_circle_rounded, + fill: ColorConstants.acceptGreen, + duration: const Duration(seconds: 2), + ); + + /// Neutral news: something changed that the rider did not ask for, or a + /// background action finished. Never used for failures. + static void info(BuildContext context, String message) => _show( + context, + message, + icon: Icons.info_outline_rounded, + fill: ColorConstants.slateText, + duration: const Duration(seconds: 3), + ); + + /// The floating nav bar's height plus the safe area, so nothing lands behind + /// it. Falls back to the plain inset on screens that have no nav bar. + static double _bottomInset(BuildContext context) { + final safe = MediaQuery.of(context).padding.bottom; + return safe + 88; + } + + /// For code with no `BuildContext` — controllers, background handlers. + /// + /// The nine `Get.snackbar` calls in the app existed because a controller has + /// no context to hand a `ScaffoldMessenger`. They arrived with GetX's own + /// visual language and its own animation, on a different overlay layer, and + /// could be occluded by the floating nav bar. This routes them through the + /// same shape as everything else. + static void errorGlobal(String message) { + final ctx = Get.context; + if (ctx != null) error(ctx, message); + } + + static void successGlobal(String message) { + final ctx = Get.context; + if (ctx != null) success(ctx, message); + } + + static void infoGlobal(String message) { + final ctx = Get.context; + if (ctx != null) info(ctx, message); + } + + static void _show( + BuildContext context, + String message, { + required IconData icon, + required Color fill, + required Duration duration, + VoidCallback? onRetry, + }) { + final messenger = ScaffoldMessenger.maybeOf(context); + if (messenger == null) return; + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Row( + children: [ + Icon(icon, color: ColorConstants.onAccent, size: 20), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: MilerType.micro.on(ColorConstants.onAccent).semibold, + ), + ), + ], + ), + backgroundColor: fill, + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.fromLTRB(16, 0, 16, _bottomInset(context)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + duration: duration, + action: onRetry == null + ? null + : SnackBarAction( + label: 'Retry', + textColor: ColorConstants.onAccent, + onPressed: onRetry, + ), + ), + ); + } +} + +/// The screen-level version of [AppFeedback.error]: shown where the content +/// would have been when a load fails and there is nothing to fall back on. +/// +/// This is the state Home and Bookings were missing entirely. A failed fetch +/// left `trips` empty, and empty renders as "Trip 1 not assigned yet" or "You're +/// all caught up" — so a network failure was reported to the rider as *good +/// news about his workload*. He would sit waiting for work that the app had +/// simply failed to ask for. +class ErrorRetry extends StatelessWidget { + final String title; + final String message; + final Future Function() onRetry; + + const ErrorRetry({ + super.key, + this.title = "Couldn't load", + this.message = + 'Check your connection — your bookings are safe on the server.', + required this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return MilerEmptyState( + icon: Icons.wifi_off_rounded, + title: title, + message: message, + actionLabel: 'Try again', + onAction: () => onRetry(), + ); + } +} diff --git a/lib/views/helpers/widgets/miler_app_bar.dart b/lib/views/helpers/widgets/miler_app_bar.dart new file mode 100644 index 0000000..5187655 --- /dev/null +++ b/lib/views/helpers/widgets/miler_app_bar.dart @@ -0,0 +1,386 @@ +import 'dart:io'; +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.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_type.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; + +/// The frosted brand surface every app bar in the app sits on. +/// +/// ── Why a filter and not just a tinted box ── +/// +/// A flat 7% red over white is a colour; glass is a *relationship* — it takes +/// whatever is underneath and softens it. Content scrolls under these bars, so +/// the blur has something real to work on: a card edge passing beneath the bar +/// blurs out rather than sliding under a solid lid, which is the whole visual +/// difference between "the page continues up there" and "the page stops here". +/// +/// Pass as `flexibleSpace` with a transparent `backgroundColor`, so the wash +/// covers the status-bar inset too — a bar tinted only below the notch reads as +/// a stripe rather than a surface. +/// +/// ── And no line under it ── +/// +/// The bar closed with a hairline for as long as it was white, because a white +/// bar over a near-white page has no edge of its own and would otherwise bleed +/// into the content. The wash *is* an edge — a tinted surface against an +/// untinted one — so the rule underneath became a second boundary drawn on top +/// of the first, which is exactly the doubled-up border this app has been +/// pulling out of its cards. Glass separates by being a different surface, not +/// by being fenced. +Widget milerGlassSurface() { + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), + child: Container(color: ColorConstants.glassRed), + ), + ); +} + +/// ── A bottom sheet as frosted glass ── +/// +/// The two sheets a rider spends the most time in — the stop's map sheet and +/// Update Status — were opaque white slabs. On the map screen that is the +/// whole problem: the sheet opens over the one thing the screen exists to +/// show, and takes 45% of it away permanently. +/// +/// Frosted instead. The map stays visible as context underneath, softened +/// enough that an address printed on top of it is still the sharpest thing in +/// the frame. It is the same material the app bar already uses +/// ([milerGlassSurface]) — the sheets now belong to the same app as the bars +/// above them, which is the thing that made them look borrowed from somewhere +/// else. +/// +/// ── What it costs ── +/// +/// A `BackdropFilter` re-samples everything behind it every frame, and this one +/// is full-width over a map that repaints as it pans. Sigma is held at 18 — +/// the same as the app bar, tuned on a mid-range Android — rather than the 30+ +/// that reads as heavier frost, because on this phone the difference between +/// them is frames, and a sheet that stutters when dragged is worse-looking than +/// one that is merely less blurry. +Widget milerGlassSheet({required Widget child, double radius = 28}) { + final shape = BorderRadius.vertical(top: Radius.circular(radius)); + + return DecoratedBox( + // Outside the clip: a shadow drawn inside its own ClipRRect is a shadow + // that has been clipped away. + decoration: BoxDecoration( + borderRadius: shape, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.13), + blurRadius: 26, + offset: const Offset(0, -6), + ), + ], + ), + child: ClipRRect( + borderRadius: shape, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), + child: DecoratedBox( + decoration: BoxDecoration( + color: ColorConstants.glassSheet, + borderRadius: shape, + // The rim, not a border: it is one pixel of light on the curve, and + // it is what separates "frosted glass" from "white box at 88%". + border: Border.all(color: ColorConstants.glassRim, width: 1), + ), + child: child, + ), + ), + ), + ); +} + +/// The Doormile mark, at the one size the whole app agrees on. +/// +/// It no longer appears in any Dashboard app bar — see [MilerAppBar] — and now +/// belongs to the screens where the rider has not started working yet: intro, +/// sign-in, MPIN. One size across all of them so the mark does not appear to +/// resize as he moves through the flow. +/// +/// ── Why this is not just a `SizedBox` around an `Image` ── +/// +/// `doormile_icon.png` is a 1024×1024 app-icon export, and the artwork inside +/// it is inset: the ink spans x/y 170→853, so it fills **66.8%** of the canvas +/// and the outer third is transparent padding. With `BoxFit.contain` that +/// padding is laid out as if it were artwork, so a 34px box drew a mark only +/// ~23px wide. That is the real reason the logo read as small — most of the box +/// it was given was empty. +/// +/// So the box grew *and* the dead margin is reclaimed: [_boxSize] sets the +/// footprint, and [_inkScale] scales the image up by the inverse of that fill +/// ratio so the artwork itself fills the box. Ink goes from ~23px to ~41px — +/// roughly 80% bigger — while the space the widget occupies grows by far less. +/// +/// The scale is safe rather than clever: the padding is exactly symmetric (170 +/// on every side), so scaling about the centre crops only transparent pixels. +/// [_inkScale] is deliberately a little under the theoretical 1.50 so the mark +/// keeps a hair of breathing room instead of touching its own bounds. +/// +/// If the asset is ever re-exported tight to the artwork, drop [_inkScale] to +/// 1.0 — do not delete the box size with it. +/// ───────────────────────────────────────────────────────────────────────── +class MilerBrandMark extends StatelessWidget { + const MilerBrandMark({super.key}); + + /// Footprint. Fixed `.w` so font scaling never resizes the mark. + static const double _boxSize = 44; + + /// 1 / 0.668, trimmed. See the class note. + static const double _inkScale = 1.42; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: _boxSize.w, + height: _boxSize.w, + // Excluded from semantics: the mark is decoration on a screen the rider + // is already looking at, and the page title beside it is the real label. + child: ExcludeSemantics( + child: Transform.scale( + scale: _inkScale, + child: Image.asset( + 'assets/images/doormile_icon.png', + fit: BoxFit.contain, + // Crisp at 3x: the source is 1024px against a ~132px target. + filterQuality: FilterQuality.medium, + ), + ), + ), + ); + } +} + +/// The rider's own face, at the size the brand mark used to occupy. +/// +/// Home is the one screen where the top-left slot belongs to the rider rather +/// than to Doormile: it is his shift, his queue, his earnings underneath. So +/// the slot holds his profile photo, and when he has not set one it holds the +/// first letter of his name on a filled circle — the same fallback every +/// contact list uses, and one that still reads as *him* rather than as a +/// generic person glyph. +/// +/// The photo is a local file path (set in Profile → change photo, persisted as +/// `profile_image_path`), so it is watched through [ProfileController] and the +/// bar repaints the moment he picks a new one. If the file has since been +/// deleted from disk — gallery cleanup, an OS cache sweep of a picked image — +/// [File.existsSync] catches it and the initial takes over rather than the +/// circle rendering as a broken box. +class MilerAvatar extends StatelessWidget { + /// Name to take the initial from. Falls back to [ProfileController.userName] + /// when null, so callers that already hold the name do not have to wait on + /// the controller and callers that do not can leave it off. + final String? name; + + const MilerAvatar({super.key, this.name}); + + /// Matches [MilerBrandMark._boxSize] — this widget takes over that slot, and + /// the bar's height is built around it. + static const double _boxSize = 44; + + @override + Widget build(BuildContext context) { + final ProfileController? controller = Get.isRegistered() + ? Get.find() + : null; + + if (controller == null) { + return _circle(_initialOf(name ?? ''), null); + } + + return Obx(() { + final String path = controller.imagePath.value; + final String source = (name ?? '').trim().isNotEmpty + ? name! + : controller.userName.value; + + File? photo; + try { + if (path.isNotEmpty && File(path).existsSync()) photo = File(path); + } catch (_) {} + + return _circle(_initialOf(source), photo); + }); + } + + /// First letter of the first word, upper-cased. 'M' for Miler when there is + /// no name yet — a blank circle would read as a loading state that never + /// resolves. + String _initialOf(String source) { + final String trimmed = source.trim(); + if (trimmed.isEmpty) return 'M'; + return trimmed.characters.first.toUpperCase(); + } + + Widget _circle(String initial, File? photo) { + return Semantics( + label: 'Your profile', + image: photo != null, + child: Container( + width: _boxSize.w, + height: _boxSize.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: photo != null + ? ColorConstants.neutralLight + : ColorConstants.primary, + image: photo != null + ? DecorationImage(image: FileImage(photo), fit: BoxFit.cover) + : null, + ), + alignment: Alignment.center, + child: photo != null + ? null + : Text( + initial, + style: TextStyle( + // 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, + height: 1, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ); + } +} + +/// One consistent top bar for every Dashboard page. +/// +/// ── No brand mark here ── +/// +/// The logo used to lead every bar. It is gone from all of them: the rider +/// opens this app to work, not to be told twice a minute whose app it is, and +/// on a pushed sub-page the mark was pure repetition of what the screen he came +/// from already said. Home keeps a mark-shaped slot but fills it with the +/// rider's own face — see [MilerAvatar]. Everywhere else the title starts at +/// the margin, which is also the widest the title has ever been able to run. +/// +/// The title takes the type weight — 23sp, tight tracking, near-black — on the +/// frosted brand wash every bar in the app shares. Bar height is 76 with the +/// content pushed down [_titleTopInset], so the type sits off the status bar +/// instead of against it. +/// +/// ── One line, not two ── +/// +/// There used to be a caption under every title: "5 stops done today", "Trip 2 · +/// 4 bookings in queue". It was the smallest type on the screen, in the one +/// place a rider never looks twice — he reads a page title once, on arrival, +/// and after that the bar is furniture. Worse, every one of those captions +/// duplicated something the page itself already said louder and lower down: the +/// trip and its progress are spelled out on the rail, the day's tally sits on +/// the Activity summary card. Two sources for one number is how they drift. +/// +/// So the bar carries the page's name and nothing else, and the page carries +/// the facts. +/// +/// Pass an optional [trailing] widget (status toggle, map button, notifications +/// icon). +class MilerAppBar extends StatelessWidget implements PreferredSizeWidget { + final String title; + final Widget? trailing; + + /// Draws a back arrow before the title. + /// + /// Off by default because this bar was built for the four tabs, which have + /// nothing to go back to. It has to be opt-in rather than automatic: the bar + /// sets `automaticallyImplyLeading: false`, so a page that is pushed rather + /// than tabbed to — Earnings, since it moved under Account — would otherwise + /// render with no way out at all. + final bool showBack; + + const MilerAppBar({ + super.key, + required this.title, + this.trailing, + this.showBack = false, + }); + + /// Fixed logical bar height. 64 → 76 to absorb [_titleTopInset] without + /// squeezing the title against the status bar. + static const double _barHeight = 76; + + /// Pushes the type down off the status bar. The bar was flush against it, + /// which read as clipped rather than as a bar. + static const double _titleTopInset = 12; + + @override + Size get preferredSize => Size.fromHeight(_barHeight); + + @override + Widget build(BuildContext context) { + return AppBar( + backgroundColor: Colors.transparent, + flexibleSpace: milerGlassSurface(), + elevation: 0, + scrolledUnderElevation: 0, + centerTitle: false, + titleSpacing: 16.w, + toolbarHeight: _barHeight, + automaticallyImplyLeading: false, + title: Padding( + padding: EdgeInsets.only(top: _titleTopInset.h), + child: Row( + children: [ + if (showBack) ...[ + // Sized to the tap-target floor and pulled left by its own + // padding, so the arrow's *glyph* still lands on the page margin + // rather than the button's box starting there. + Padding( + padding: EdgeInsets.only(right: 4.w), + child: Semantics( + button: true, + label: 'Back', + child: InkWell( + onTap: () => Navigator.of(context).maybePop(), + 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, + size: 22.sp, + color: ColorConstants.slateText, + ), + ), + ), + ), + ), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // One page-title size app-wide. This was 23 while pushed + // screens were 22 and 21 — a one-point difference nobody can + // name and everybody sees as the heading jumping on tab + // change. + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: MilerType.pageTitleFixed, + ), + ], + ), + ), + if (trailing != null) ...[SizedBox(width: 12.w), trailing!], + ], + ), + ), + ); + } +} diff --git a/lib/views/helpers/widgets/miler_map.dart b/lib/views/helpers/widgets/miler_map.dart new file mode 100644 index 0000000..0b04e36 --- /dev/null +++ b/lib/views/helpers/widgets/miler_map.dart @@ -0,0 +1,447 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart' show LatLng; + +import 'package:miler/helpers/miler_router.dart'; + +/// The router travels with the map: every screen that draws one also asks for a +/// route, and neither is any use without the other. +export 'package:miler/helpers/miler_router.dart' show MilerRouter; + +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'; + +/// ───────────────────────────────────────────────────────────────────────── +/// MAPS, WITHOUT A KEY +/// +/// Every map in this app was a `GoogleMap`, and every one of them rendered +/// blank: the key the app shipped with is revoked, so the tile requests, the +/// marker layer and the Directions call all failed at once. A blank grey +/// rectangle where the route should be is indistinguishable from a broken app, +/// and no amount of Dart fixes it — the failure is entirely in credentials. +/// +/// So the maps no longer need credentials. +/// +/// • **Tiles** — OpenStreetMap raster tiles, served free and keyless, drawn by +/// `flutter_map`. Nothing to provision, nothing to revoke. +/// • **Routes** — OSRM's public router, also free and keyless, in place of the +/// Directions API. It returns the same thing the app used: a driving +/// geometry between two points. +/// +/// ── What this costs, and what it buys ── +/// +/// Both services are community-run and neither is contracted for production +/// traffic: OSM's tile policy and OSRM's demo server are fine for development +/// and a small fleet, and will rate-limit a large one. Swapping either is one +/// constant ([kMilerTileUrl]) and one function ([MilerRouter.route]) — that is +/// why they are here rather than inlined at four call sites. See the note in +/// README before shipping to a real fleet. +/// +/// What it buys immediately: the map is visible again on every screen, and +/// markers stop being textures. A Google Maps marker is a `BitmapDescriptor` — +/// a bitmap the app has to *paint on a canvas* to put a number in a circle +/// (`multi_map.dart` had 100 lines doing exactly that). Here a marker is a +/// widget, so it is drawn with the same colours, fonts and radii as the rest of +/// the app, and it changes when the theme does. +/// ───────────────────────────────────────────────────────────────────────── + +/// Standard OSM raster tiles. Keyless. +const String kMilerTileUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + +/// Sent with every tile request. OSM's policy requires an identifiable agent — +/// an app that does not send one is the first thing they block. +const String kMilerTileAgent = 'com.doormile.partner'; + +/// ── The map surface ── +/// +/// One widget for all four map screens, so tile source, attribution, gesture +/// rules and the "no rotation" decision are made once. A rider on a bike does +/// not want a map that spins because his thumb dragged crooked, and a rotated +/// map with north somewhere else is unreadable at a glance — which is the only +/// way this map is ever read. +class MilerMap extends StatelessWidget { + /// Optional: a map nobody drives — a static thumbnail in a sheet — needs no + /// controller, and creating one per build would leak a disposable. + final MapController? controller; + final LatLng initialCenter; + final double initialZoom; + final List polylines; + final List markers; + final VoidCallback? onReady; + + /// Extra layers drawn under the markers — accuracy circles, and the like. + final List underlays; + + const MilerMap({ + super.key, + this.controller, + required this.initialCenter, + this.initialZoom = 14, + this.polylines = const [], + this.markers = const [], + this.underlays = const [], + this.onReady, + }); + + @override + Widget build(BuildContext context) { + return FlutterMap( + mapController: controller, + options: MapOptions( + initialCenter: initialCenter, + initialZoom: initialZoom, + minZoom: 3, + maxZoom: 18, + backgroundColor: ColorConstants.surfaceContainerLow, + interactionOptions: const InteractionOptions( + // Everything except rotation. + flags: + InteractiveFlag.drag | + InteractiveFlag.flingAnimation | + InteractiveFlag.pinchMove | + InteractiveFlag.pinchZoom | + InteractiveFlag.doubleTapZoom, + ), + onMapReady: onReady, + ), + children: [ + TileLayer( + urlTemplate: kMilerTileUrl, + userAgentPackageName: kMilerTileAgent, + maxNativeZoom: 19, + // Keeps the last good tile on screen while the next zoom loads, + // instead of flashing the background through. + keepBuffer: 4, + ), + if (polylines.isNotEmpty) PolylineLayer(polylines: polylines), + ...underlays, + if (markers.isNotEmpty) MarkerLayer(markers: markers), + const _MilerAttribution(), + ], + ); + } +} + +/// OSM's licence requires visible credit. Kept to a hairline chip in the +/// corner: it is a legal obligation, not a piece of the interface, and the +/// rider is here for the route. +class _MilerAttribution extends StatelessWidget { + const _MilerAttribution(); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.bottomLeft, + child: Padding( + padding: const EdgeInsets.only(left: 6, bottom: 4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + child: Text( + '© OpenStreetMap', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ), + ); + } +} + +// ── Camera ────────────────────────────────────────────────────────────────── + +/// Animated camera moves for a [MapController]. +/// +/// `flutter_map`'s controller only teleports — `move` and `fitCamera` both take +/// effect on the next frame. The route screens were built around a camera that +/// eases: the line waits for the camera to land before it draws, and a cut +/// makes that choreography read as a glitch instead of as a flight. So the +/// tween lives here, over the controller's instant moves. +class MilerMapCamera { + final MapController controller; + final TickerProvider vsync; + + AnimationController? _anim; + + MilerMapCamera({required this.controller, required this.vsync}); + + void dispose() { + _anim?.dispose(); + _anim = null; + } + + /// Eases to the camera that frames [points], and completes when it lands. + Future fit( + List points, { + EdgeInsets padding = const EdgeInsets.all(60), + Duration duration = const Duration(milliseconds: 650), + double maxZoom = 16.5, + }) async { + if (points.isEmpty) return; + final fit = CameraFit.coordinates( + coordinates: points, + padding: padding, + maxZoom: maxZoom, + ).fit(controller.camera); + await moveTo(fit.center, fit.zoom, duration: duration); + } + + /// Eases to a centre and zoom, and completes when it lands. + Future moveTo( + LatLng target, + double zoom, { + Duration duration = const Duration(milliseconds: 650), + }) async { + _anim?.dispose(); + + final startCentre = controller.camera.center; + final startZoom = controller.camera.zoom; + if (duration == Duration.zero) { + controller.move(target, zoom); + return; + } + + final anim = AnimationController(vsync: vsync, duration: duration); + _anim = anim; + final curve = CurvedAnimation(parent: anim, curve: Curves.easeInOutCubic); + + final completer = Completer(); + void tick() { + final t = curve.value; + controller.move( + LatLng( + startCentre.latitude + (target.latitude - startCentre.latitude) * t, + startCentre.longitude + + (target.longitude - startCentre.longitude) * t, + ), + startZoom + (zoom - startZoom) * t, + ); + } + + anim.addListener(tick); + anim.forward().whenComplete(() { + if (!completer.isCompleted) completer.complete(); + }); + return completer.future; + } +} + +// ── Pins ──────────────────────────────────────────────────────────────────── + +/// A map pin, as a widget. +/// +/// The stock Google teardrop came in nine fixed hues, none of which is this +/// app's maroon, so a pickup and a delivery were told apart by "red-ish" and +/// "green-ish" and by nothing else. These take [StopKindUi.accent] directly, so +/// the pin on the map is the exact colour of the chip on the card the rider +/// tapped to get here. +class MilerPin extends StatelessWidget { + final Color color; + final IconData icon; + + /// Draws the white ring and shadow larger, for the stop being worked. + final bool emphasised; + + const MilerPin({ + super.key, + required this.color, + required this.icon, + this.emphasised = false, + }); + + static const double size = 40; + static const double tail = 10; + + @override + Widget build(BuildContext context) { + final head = emphasised ? size : size - 6; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: head, + height: head, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.28), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Icon(icon, size: head * 0.46, color: Colors.white), + ), + // The stem that makes a circle read as "this exact point" rather than + // "somewhere under here". + CustomPaint( + size: const Size(12, tail), + painter: _PinTailPainter(color), + ), + ], + ); + } +} + +/// A numbered stop on a multi-stop route. Same shape as [MilerPin], carrying +/// the stop's position in the run instead of an icon — which is what the rider +/// is matching against the list below. +class MilerNumberPin extends StatelessWidget { + final int number; + final Color color; + final bool done; + + const MilerNumberPin({ + super.key, + required this.number, + required this.color, + this.done = false, + }); + + static const double size = 34; + static const double tail = 8; + + @override + Widget build(BuildContext context) { + final fill = done ? ColorConstants.acceptGreen : color; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: fill, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2.5), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.25), + blurRadius: 7, + offset: const Offset(0, 3), + ), + ], + ), + child: done + ? const Icon(Icons.check_rounded, size: 16, color: Colors.white) + : Text( + '$number', + style: const TextStyle( + fontSize: 14, + height: 1, + fontWeight: FontWeight.w800, + color: Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + CustomPaint(size: const Size(10, tail), painter: _PinTailPainter(fill)), + ], + ); + } +} + +/// The rider's own position: a dot, not a pin. A pin says "a thing is here"; +/// a dot says "you are here", and every map app in the world agrees on it. +class MilerRiderDot extends StatelessWidget { + const MilerRiderDot({super.key}); + + static const double size = 22; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: ColorConstants.deliveryAccent, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow( + color: ColorConstants.deliveryAccent.withValues(alpha: 0.45), + blurRadius: 10, + spreadRadius: 2, + ), + ], + ), + ); + } +} + +class _PinTailPainter extends CustomPainter { + final Color color; + const _PinTailPainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final path = Path() + ..moveTo(0, 0) + ..lineTo(size.width / 2, size.height) + ..lineTo(size.width, 0) + ..close(); + canvas.drawPath( + path, + Paint() + ..color = color + ..style = PaintingStyle.fill, + ); + } + + @override + bool shouldRepaint(_PinTailPainter old) => old.color != color; +} + +/// A marker anchored so its *point* sits on the coordinate, not its centre. +Marker milerMarker({ + required LatLng point, + required Widget child, + required double width, + required double height, +}) { + return Marker( + point: point, + width: width, + height: height, + // The pin's tip is at the bottom edge of its box, so the box hangs above + // the coordinate. Google did this for us; flutter_map centres by default, + // which puts the tip half a pin south of the address. + alignment: Alignment.topCenter, + child: child, + ); +} + +/// Metres between two coordinates, on the great circle. +/// +/// `Geolocator.distanceBetween` does this too, but it is a platform call: it +/// cannot run in a test, and the route screens ask for it once per point of a +/// several-hundred-point line. +double milerDistanceMetres(LatLng a, LatLng b) { + const earth = 6371000.0; + final dLat = _rad(b.latitude - a.latitude); + final dLng = _rad(b.longitude - a.longitude); + final h = + math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_rad(a.latitude)) * + math.cos(_rad(b.latitude)) * + math.sin(dLng / 2) * + math.sin(dLng / 2); + return earth * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h)); +} + +double _rad(double deg) => deg * math.pi / 180.0; diff --git a/lib/views/helpers/widgets/page_transitions.dart b/lib/views/helpers/widgets/page_transitions.dart new file mode 100644 index 0000000..0f43f13 --- /dev/null +++ b/lib/views/helpers/widgets/page_transitions.dart @@ -0,0 +1,278 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; + +/// ─────────────────────────────────────────────────────────────────────────── +/// PAGE MOTION — one vocabulary for every screen you can navigate into +/// +/// Before this file the app had three different answers to "what happens when +/// I tap a row": `Get.to` with GetX's default transition, a bare +/// `MaterialPageRoute` (which on Android is the fade-through zoom, and reads as +/// a hard cut on mid-range hardware), and a `pushReplacement` with no +/// transition intent at all. Tapping *Edit Profile* looked nothing like tapping +/// *Open map*, and neither looked like anything. +/// +/// Two motions, and only two, from here on: +/// +/// • [MilerSlideRoute] — right → left. For the *work*: a stop's map, the +/// multi-stop route, the next step of onboarding. These are places the +/// rider goes on his way somewhere else, so they behave like pages — the +/// one below parallaxes, and a left-edge swipe goes back. +/// +/// • [MilerSheetRoute] — bottom → top, and back down the same way. For +/// anything laid *over* what the rider was doing: every screen under +/// Account, plus stop verification and payment collection. It rises, it +/// covers, it drops away. +/// +/// ── Why Account is all sheets ── +/// +/// These screens are leaves. You open Notifications, Saved Address, FAQ or +/// Terms to do one thing, and then you are done with it — there is nowhere +/// onward to go, which is why they all close with an × rather than a back +/// arrow. A cross that dismissed a page sideways contradicted itself: the icon +/// promised a layer dropping away and the motion showed a page being left +/// behind. Now the icon and the direction say the same thing, which is what +/// Uber's account screens do. +/// +/// Both routes share the same expo-out entrance — fast off the mark, long soft +/// landing, motion that arrives rather than stops. They differ on the way out, +/// and deliberately: see [kMilerExitCurve] and [kMilerSheetExitCurve]. +/// ─────────────────────────────────────────────────────────────────────────── + +/// Fast start, long settle. The thing that makes a push feel "smooth" rather +/// than "fast" — most of the distance is covered early, so the eye reads it as +/// responsive, and the tail is slow enough that it never appears to snap. +const Curve kMilerEnterCurve = Cubic(0.16, 1.0, 0.30, 1.0); + +/// Leaving a *screen* — a map, a stop, something you were working inside. +/// The rider's decision is already made, so this gets out of the way. +const Curve kMilerExitCurve = Cubic(0.4, 0.0, 0.7, 0.2); + +/// Leaving a *sheet*, which is a different gesture entirely. +/// +/// A task screen dropping back down is the app putting something away, and the +/// eye follows it the whole way — so it is eased at both ends rather than +/// snatched off the screen. [kMilerExitCurve] was written for a page sliding +/// sideways out of view, where nobody is watching the tail; used on a +/// full-height sheet it read as the page being deleted rather than closed. +const Curve kMilerSheetExitCurve = Cubic(0.33, 0.0, 0.15, 1.0); + +// ── The 300ms ceiling ── +// +// 360/400/380 were within a hair of each other and all above the ceiling the +// motion scale sets for anything on the rider's critical path. He crosses these +// transitions 40 times a shift; 60–100ms each way is time spent watching the +// app instead of working. The curves are unchanged — they are what make the +// motion read as considered — only the duration comes down. +const Duration kMilerPushIn = DesignConstants.motionPage; +const Duration kMilerPushOut = Duration(milliseconds: 240); +const Duration kMilerSheetIn = DesignConstants.motionPage; + +/// Deliberately close to the entrance, and much slower than the 290ms it was. +/// +/// Dismissal had been treated as dead time to be minimised — the standard +/// instinct, and wrong here. Tapping × sent a full-height page down the screen +/// in under a third of a second, which does not read as "closing", it reads as +/// the screen vanishing. Going and coming should take about the same time, +/// because they are the same distance. +const Duration kMilerSheetOut = Duration(milliseconds: 240); + +/// Right → left drill-down, with the iOS parallax on the page underneath and +/// an interactive left-edge back-swipe. +/// +/// Cupertino's own 500ms is a beat too slow for a screen a rider opens forty +/// times a shift, so the duration is pulled in; the curve stays Cupertino's +/// because it is what the back-gesture physics are written against. +class MilerSlideRoute extends CupertinoPageRoute { + /// Off for screens whose own content wants horizontal drags — a full-bleed + /// Google Map, most of all. There, an edge swipe meant to nudge the map would + /// throw the rider out of live navigation, so the back arrow is the only way + /// out and that is the correct trade. + final bool swipeToGoBack; + + MilerSlideRoute({ + required super.builder, + this.swipeToGoBack = true, + super.settings, + super.maintainState, + }); + + @override + Duration get transitionDuration => kMilerPushIn; + + @override + Duration get reverseTransitionDuration => kMilerPushOut; + + @override + bool get popGestureEnabled => swipeToGoBack && super.popGestureEnabled; +} + +/// Bottom → top task screen, Uber-style. +/// +/// The incoming page rises the full height of the screen on the expo-out curve +/// while the page it covers eases back a few percent — the small counter-motion +/// is what stops a full-screen slab from reading as a jump-cut. When a further +/// screen is pushed on top of *this* one, it slides up and out of the way by +/// the same small amount, so a two-deep task flow still parallaxes. +class MilerSheetRoute extends PageRouteBuilder { + MilerSheetRoute({ + required WidgetBuilder builder, + super.settings, + super.maintainState, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => + builder(context), + transitionDuration: kMilerSheetIn, + reverseTransitionDuration: kMilerSheetOut, + // A task screen is a modal presentation: Flutter uses this to keep + // the page below from running its own outgoing slide, which would + // fight the upward motion. + fullscreenDialog: true, + transitionsBuilder: _buildSheetTransition, + ); + + static Widget _buildSheetTransition( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final Animation enter = + Tween(begin: const Offset(0, 1), end: Offset.zero).animate( + CurvedAnimation( + parent: animation, + curve: kMilerEnterCurve, + reverseCurve: kMilerSheetExitCurve, + ), + ); + + // Pushed over by yet another screen: retreat slightly upward instead of + // sitting frozen under the new page. + final Animation recede = + Tween(begin: Offset.zero, end: const Offset(0, -0.06)).animate( + CurvedAnimation( + parent: secondaryAnimation, + curve: kMilerEnterCurve, + reverseCurve: kMilerSheetExitCurve, + ), + ); + + return SlideTransition( + position: recede, + child: SlideTransition(position: enter, child: child), + ); + } +} + +/// ── Call sites ────────────────────────────────────────────────────────────── +/// +/// `openScreen(context, const FaqPage())` reads at the tap site as "go deeper", +/// `openSheet(context, const Profile())` as "do a thing and come back". Both +/// return the popped result, so they drop straight into the places that were +/// awaiting a `Navigator.push`. + +/// Right → left. The default for anything you navigate *into*. +/// +/// Pass `swipeToGoBack: false` when the destination is a map or anything else +/// that handles its own horizontal drags. +Future openScreen( + BuildContext context, + Widget page, { + bool swipeToGoBack = true, +}) { + return Navigator.of(context).push( + MilerSlideRoute(builder: (_) => page, swipeToGoBack: swipeToGoBack), + ); +} + +/// Bottom → top. For task screens you complete or abandon. +Future openSheet(BuildContext context, Widget page) { + return Navigator.of( + context, + ).push(MilerSheetRoute(builder: (_) => page)); +} + +/// Bottom → top, replacing the current screen — for the end of a flow, where +/// going "back" to the screen you just finished would be wrong. +Future replaceWithSheet(BuildContext context, Widget page) { + return Navigator.of( + context, + ).pushReplacement(MilerSheetRoute(builder: (_) => page)); +} + +/// ─────────────────────────────────────────────────────────────────────────── +/// THE SMALLER SURFACES — dialogs and bottom sheets +/// +/// A page is not the only thing that arrives. The app asks for confirmation in +/// dialogs and offers choices in sheets far more often than it pushes a screen, +/// and both were using framework defaults that predate everything above. +/// ─────────────────────────────────────────────────────────────────────────── + +/// Bottom sheets: Flutter's default is 250ms in / 200ms out on +/// `decelerate` — quick enough to register as a pop rather than a rise, which +/// is especially wrong for the sheets a rider reads (skip reasons, stop +/// details, payment) before deciding something. Same curve as the pages, so a +/// sheet and a pushed screen feel like the same hand moved them. +/// +/// Pass as `sheetAnimationStyle:` to `showModalBottomSheet`. +const AnimationStyle kMilerSheetStyle = AnimationStyle( + duration: Duration(milliseconds: 340), + curve: kMilerEnterCurve, + reverseDuration: Duration(milliseconds: 300), + reverseCurve: kMilerSheetExitCurve, +); + +/// For the tall ones — a sheet that covers three-quarters of the screen is +/// closer to a page than to a picker, and travelling that far in 340ms reads as +/// a slam. Distance wants time: the same curve, given room to decelerate. +const AnimationStyle kMilerLargeSheetStyle = AnimationStyle( + duration: Duration(milliseconds: 430), + curve: kMilerEnterCurve, + reverseDuration: Duration(milliseconds: 360), + reverseCurve: kMilerSheetExitCurve, +); + +/// Confirmation dialogs that grow into place instead of blinking on. +/// +/// Material's `showDialog` is a 150ms straight fade with no movement at all — +/// the dialog is simply there, at full size, before the eye has found it. This +/// keeps the fade but adds the small scale-up that tells you *where* it came +/// from, and gives the barrier the same clock so the screen dims with it rather +/// than a beat ahead. +/// +/// Drop-in for `showDialog`: same `context`/`builder`/`barrierDismissible`. +Future showAppDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, + Color barrierColor = const Color(0x8A000000), + bool useRootNavigator = true, +}) { + return showGeneralDialog( + context: context, + barrierDismissible: barrierDismissible, + barrierLabel: barrierDismissible + ? MaterialLocalizations.of(context).modalBarrierDismissLabel + : null, + barrierColor: barrierColor, + useRootNavigator: useRootNavigator, + transitionDuration: const Duration(milliseconds: 260), + pageBuilder: (context, _, _) => 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 + // listener-holding object each time. A CurveTween is a pure function and + // reads the same in reverse, so dismissal is the arrival played backwards. + final Animation curved = animation.drive( + CurveTween(curve: Curves.easeOutCubic), + ); + return FadeTransition( + opacity: curved, + child: ScaleTransition( + scale: curved.drive(Tween(begin: 0.94, end: 1.0)), + child: child, + ), + ); + }, + ); +} diff --git a/lib/views/helpers/widgets/settings_ui.dart b/lib/views/helpers/widgets/settings_ui.dart new file mode 100644 index 0000000..39f01b3 --- /dev/null +++ b/lib/views/helpers/widgets/settings_ui.dart @@ -0,0 +1,336 @@ +import 'package:flutter/material.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/widgets/app_widgets.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; + +/// ─────────────────────────────────────────────────────────────────────────── +/// THE ACCOUNT SECTION'S VISUAL LANGUAGE +/// +/// Account is nine screens deep — Edit Profile, Saved Addresses, Alert Sound, +/// Notifications, FAQ, Help, Contact Support, Terms, Rewards — and until now +/// each one had been drawn by hand. They disagreed about the page colour (a +/// warm surface on one, `grey.shade50` on the next, `grey.shade100` on a +/// third), about the card (18r hairline here, 16r `grey.shade100` there, a +/// 1.5px `borderStrong` on a fourth), about text colour (tokens on some pages, +/// `Colors.black87` and `grey.shade600` on others), and about whether type +/// scaled with the device at all. Tapping the bell from Account moved the rider +/// to what looked like a different product. +/// +/// The fix is not to restyle nine files nine times. It is to have one card, one +/// row and one page body, and to let every screen in the section be assembled +/// from them — which is what this file is. The look is the Account page's own, +/// because that is the screen the rider sees first and the one the others have +/// to agree with. +/// +/// Colour comes from [ColorConstants] only. Nothing in this file, and nothing +/// built on it, should name a hex value or a `Colors.*` swatch: the brand is +/// red, the neutrals are tokens, and a screen that invents its own grey is a +/// screen that will drift again the next time someone edits it. +/// ─────────────────────────────────────────────────────────────────────────── + +/// The one card. White, softly lifted, hairline edge — the Account page's. +class SettingsCard extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry? padding; + + const SettingsCard({super.key, required this.child, this.padding}); + + 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), + ), + ], + ); + + @override + Widget build(BuildContext context) { + return Container(padding: padding, decoration: decoration(), child: child); + } +} + +/// A list of [SettingsRow]s, hairline-separated, sitting directly on the page. +/// +/// ── No card ── +/// +/// These groups used to be cards: a filled panel with a hairline border and a +/// soft shadow, one per section. On the Account page that meant five stacked +/// rectangles — Money, Account, Appearance, Support, Logout — each drawn round +/// rows that were already separated by a section heading and a 22pt gap. The +/// box was saying, a second time, what the heading and the whitespace had +/// already said. +/// +/// It cost something, too. A card is a surface, and a surface has to be told +/// apart from the page behind it — so the whole Account page became a stack of +/// grey slabs, with nothing on screen that was actually a *thing* rather than a +/// container for things. +/// +/// Uber's account page has no cards at all: section label, rows, hairline +/// between them, gap, next section. The list *is* the structure. That is what +/// this is now — and because every screen in this section is assembled from +/// this file, all nine of them lose the boxes together. +/// +/// ── And no rules between the rows ── +/// +/// With the card gone, the hairline under every row was the last thing still +/// drawing boxes: nine rules down the Account page, each separating two lines +/// that are already a title and a subtitle apart. Uber's account list has none. +/// The rows sit close, the type does the separating, and the section headings +/// are the only structure on the page. +/// +/// [ruled] puts them back for the few lists that genuinely need one — a long +/// choice list of identical single-line rows, where nothing else distinguishes +/// one from the next. +/// +/// Pass the rows in order; the divider is drawn here rather than by each row, +/// so a row never has to know whether it is last. +class SettingsGroup extends StatelessWidget { + final List rows; + + /// Draws a hairline between rows. Off by default. + final bool ruled; + + /// Where that hairline starts, measured from the group's left edge. Clears + /// the icon column so it lines up under the text. + final double dividerIndent; + + const SettingsGroup({ + super.key, + required this.rows, + this.ruled = false, + double? dividerIndent, + }) : dividerIndent = dividerIndent ?? 38; + + @override + Widget build(BuildContext context) { + if (!ruled) return Column(children: rows); + + final List children = []; + for (int i = 0; i < rows.length; i++) { + children.add(rows[i]); + if (i != rows.length - 1) { + children.add( + Padding( + padding: EdgeInsets.only(left: dividerIndent.w), + child: Divider( + height: 1, + thickness: 1, + color: ColorConstants.dividerColor, + ), + ), + ); + } + } + return Column(children: children); + } +} + +/// One tappable line in a [SettingsGroup]: icon, title, optional second line, +/// and a chevron unless something else is given. +/// +/// [PressScale] rather than a bare tap target, because most of these rows open +/// a screen that takes ~360ms to arrive — without a press state the rider gets +/// no acknowledgement at all in that gap. +class SettingsRow extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final VoidCallback? onTap; + + /// Replaces the chevron — a switch, a value, a badge. + final Widget? trailing; + + /// Destructive actions (Logout). Draws in the brand red and drops the + /// chevron, since nothing is being navigated to. + final bool danger; + + /// Overrides the icon tint — for a row whose icon carries state, such as the + /// filled check on the chosen alert sound. + final Color? iconColor; + + const SettingsRow({ + super.key, + required this.icon, + required this.title, + this.subtitle, + this.onTap, + this.trailing, + this.danger = false, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + final Color fg = danger ? ColorConstants.primary : ColorConstants.onSurface; + + return PressScale( + onTap: onTap, + // Announced as one button named by its title and second line, rather than + // three unlabelled text fragments and an icon. + 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: Row( + children: [ + Icon(icon, color: iconColor ?? fg, size: 22.sp), + SizedBox(width: 14.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 15.sp, + fontWeight: FontWeight.w600, + color: fg, + fontFamily: FontConstants.fontFamily, + ), + ), + 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, + ), + ], + ), + ), + ); + } +} + +/// The small caps label that names a group of cards. +class SettingsLabel extends StatelessWidget { + final String text; + const SettingsLabel(this.text, {super.key}); + + @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, + ), + ), + ); + } +} + +/// A short explanatory line under a card — the place for the sentence that +/// stops a rider guessing what a setting does. +class SettingsHint extends StatelessWidget { + final String text; + const SettingsHint(this.text, {super.key}); + + @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, + ), + ), + ); + } +} + +/// The page body every screen in the section uses: one scroll view, one set of +/// margins, one entrance animation, and — crucially — one pull-to-refresh +/// behaviour. +/// +/// ── Why the physics matter ── +/// +/// Several of these pages used `BouncingScrollPhysics`, which forces the iOS +/// rubber-band on every platform. Rubber-band overscroll is *unbounded*: with +/// no `RefreshIndicator` to catch the gesture and stop it, a pull-down dragged +/// the whole page off the bottom of the screen against a blank gap and sprang +/// back having done nothing. The gesture responded, so it read as a refresh +/// that had failed. Home was fixed this way months ago; this brings the rest of +/// the app in line. +/// +/// Pass [onRefresh] and the pull does what it looks like it does. Omit it and +/// the page still refuses to rubber-band into empty space. +class SettingsBody extends StatelessWidget { + final List children; + final Future Function()? onRefresh; + final EdgeInsetsGeometry? padding; + + const SettingsBody({ + super.key, + required this.children, + this.onRefresh, + this.padding, + }); + + @override + Widget build(BuildContext context) { + final Widget scroll = SingleChildScrollView( + // `AlwaysScrollable` so a short page can still be pulled, composed with + // the platform default rather than overriding it — clamped on Android, + // bouncing on iOS, bounded on both. + physics: const AlwaysScrollableScrollPhysics(), + padding: padding ?? EdgeInsets.fromLTRB(16.w, 14.h, 16.w, 28.h), + child: Reveal( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: children, + ), + ), + ); + + return SafeArea( + bottom: false, + child: onRefresh == null + ? scroll + : RefreshIndicator( + onRefresh: onRefresh!, + color: ColorConstants.primary, + backgroundColor: ColorConstants.surfaceContainerLowest, + child: scroll, + ), + ); + } +} diff --git a/lib/views/introscreens/introscreen.dart b/lib/views/introscreens/introscreen.dart new file mode 100644 index 0000000..41ec84a --- /dev/null +++ b/lib/views/introscreens/introscreen.dart @@ -0,0 +1,646 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:miler/views/onboardscreens/Sign_in.dart'; +import 'package:miler/views/onboardscreens/auth_scaffold.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; + +/// ═════════════════════════════════════════════════════════════════════════ +/// FIRST RUN — three slides, then sign in. +/// +/// ── The copy described a different app ── +/// +/// This is the most important change here, and it is not a visual one. The old +/// slides said "Find pickups near you — see nearby pickup requests the moment +/// they arrive, accept in a single tap" and "Navigate the smart way". That is a +/// gig-marketplace pitch, and Miler is not a marketplace: the **hub assigns a +/// slot**, the route comes with its stops and its order already set, it is a +/// **round trip** that starts and ends at the hub, and a stop that is not ready +/// gets **skipped, never reordered**. A rider onboarded on the old copy would +/// open the app looking for a feed of nearby jobs to claim and not find one. +/// +/// The third slide also carried a "Powered by AI" chip next to a claim about +/// real-time bonuses. Neither was backed by anything in the app, and a promise +/// made during onboarding is the one a rider remembers when it does not hold. +/// +/// ── The illustrations show the actual product ── +/// +/// Each slide used to draw a 320×320 rounded panel, containing a 156px tinted +/// circle, containing an 84px Material icon — three nested shapes to present one +/// glyph, on a `repeat(reverse: true)` pulse that never stopped. A location pin +/// does not teach anyone what a slot route is. +/// +/// So the panels now hold small, honest mock-ups built from the app's own +/// vocabulary — the same slot chip, stop rows and hub markers the rider will see +/// on Home an hour later. Onboarding that previews the real interface is doing +/// two jobs at once: it explains the model *and* it makes the real screen +/// familiar on arrival. +/// ═════════════════════════════════════════════════════════════════════════ +class _Slide { + final String title; + final String body; + final Widget art; + const _Slide({required this.title, required this.body, required this.art}); +} + +class Introscreen extends StatefulWidget { + const Introscreen({super.key}); + + @override + State createState() => _IntroscreenState(); +} + +class _IntroscreenState extends State { + final PageController _controller = PageController(); + int _index = 0; + + static final _slides = <_Slide>[ + _Slide( + title: 'Your route arrives ready', + body: + 'Your hub assigns each shift as a time slot, with the stops and ' + 'their order already set. Take the whole trip, or accept it stop by ' + 'stop.', + art: _SlotArt(), + ), + _Slide( + title: 'Work the stops in order', + body: + 'Follow the list as it stands. If a stop is not ready, skip it and ' + 'keep moving — the order is never rearranged underneath you.', + art: _StopsArt(), + ), + _Slide( + title: 'Collect, then close the loop', + body: + 'Take the parcels and any cash due, capture the proof, and finish ' + 'back at the hub. Every stop is logged as you go.', + art: _HubArt(), + ), + ]; + + Future _finish() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('has_seen_intro', true); + } catch (_) {} + if (!mounted) return; + Get.offAll(() => const SignIn()); + } + + void _next() { + if (_index < _slides.length - 1) { + _controller.nextPage( + duration: const Duration(milliseconds: 380), + curve: Curves.easeOutCubic, + ); + } else { + _finish(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isLast = _index == _slides.length - 1; + + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark.copyWith( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + ), + child: Scaffold( + backgroundColor: ColorConstants.surface, + body: SafeArea( + child: Column( + children: [ + // ── Top bar: mark, and a way out ── + Padding( + padding: EdgeInsets.fromLTRB(20.w, 8.h, 12.w, 0), + child: Row( + children: [ + const MilerBrandMark(), + const Spacer(), + // Skip stays mounted on the last slide instead of fading to + // `opacity: 0` — an invisible-but-present control is a trap + // for screen readers, and on the last slide the primary + // button already does what Skip would. + if (!isLast) + AuthTextAction(label: 'Skip', onPressed: _finish), + ], + ), + ), + + Expanded( + child: PageView.builder( + controller: _controller, + onPageChanged: (i) => setState(() => _index = i), + itemCount: _slides.length, + itemBuilder: (_, i) => _SlideView( + slide: _slides[i], + controller: _controller, + index: i, + ), + ), + ), + + // ── Position ── + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(_slides.length, (i) { + final active = i == _index; + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + margin: EdgeInsets.symmetric(horizontal: 3.w), + height: 6.h, + width: active ? 26.w : 6.h, + decoration: BoxDecoration( + color: active + ? ColorConstants.primary + : ColorConstants.borderStrong, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + ); + }), + ), + + Padding( + padding: EdgeInsets.fromLTRB(24.w, 22.h, 24.w, 14.h), + child: AuthPrimaryButton( + label: isLast ? 'Get started' : 'Next', + onPressed: _next, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// One slide. Parallax is driven by an [AnimatedBuilder] on the page controller +/// rather than by a listener calling `setState` on the whole screen, which is +/// what the old version did on every pixel of every drag. +class _SlideView extends StatelessWidget { + final _Slide slide; + final PageController controller; + final int index; + + const _SlideView({ + required this.slide, + required this.controller, + required this.index, + }); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + // `page` is null until the viewport has been laid out once. + final page = controller.hasClients && controller.position.haveDimensions + ? (controller.page ?? controller.initialPage.toDouble()) + : controller.initialPage.toDouble(); + final delta = (page - index).abs().clamp(0.0, 1.0); + + return Opacity( + opacity: 1 - (delta * 0.55), + child: Transform.scale( + scale: 1 - (delta * 0.08), + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 8.h), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + slide.art, + SizedBox(height: 34.h), + Text( + slide.title, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 25.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.6, + height: 1.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 10.h), + Text( + slide.body, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w500, + height: 1.5, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ILLUSTRATIONS +// +// Built from the app's real vocabulary — white surfaces, 1.5px `borderStrong` +// edges, slate type, the hub teal — so the first thing the rider sees is the +// thing he will be using. Static: no controllers, nothing to dispose, nothing +// left repainting behind the keyboard. +// ═══════════════════════════════════════════════════════════════════════════ + +/// Shared frame: the "screen" each mock sits on. +class _ArtFrame extends StatelessWidget { + final Widget child; + const _ArtFrame({required this.child}); + + @override + Widget build(BuildContext context) { + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: 300.w), + child: Container( + padding: EdgeInsets.all(16.w), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + border: Border.all(color: ColorConstants.borderStrong, width: 1.5), + boxShadow: const [ + BoxShadow( + color: Color(0x0F000000), + blurRadius: 20, + offset: Offset(0, 8), + ), + ], + ), + child: child, + ), + ), + ); + } +} + +const Color _hubTeal = Color(0xFF0E7C7B); + +TextStyle _artTitle(double size) => TextStyle( + fontSize: size.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, +); + +TextStyle _artMeta(double size) => TextStyle( + fontSize: size.sp, + fontWeight: FontWeight.w700, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, +); + +/// Slide 1 — a slot, its load, and the shape of the trip. +class _SlotArt extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _ArtFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('12:00 PM – 3:00 PM', style: _artTitle(16)), + SizedBox(height: 3.h), + Text('6 stops', style: _artMeta(11.5)), + ], + ), + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 5.h), + decoration: BoxDecoration( + color: ColorConstants.neutralLight, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + border: Border.all(color: ColorConstants.borderStrong), + ), + child: Row( + children: [ + Icon( + Icons.inventory_2_rounded, + size: 11.sp, + color: ColorConstants.slateText, + ), + SizedBox(width: 4.w), + Text('15', style: _artTitle(12)), + ], + ), + ), + ], + ), + SizedBox(height: 14.h), + // The metric strip, in miniature. + Container( + padding: EdgeInsets.symmetric(vertical: 8.h), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + border: Border.all( + color: ColorConstants.borderStrong, + width: 1.5, + ), + ), + child: Row( + children: [ + _cell('≈3h', 'Duration', ColorConstants.primary), + _divider(), + _cell('50 km', 'Round trip', ColorConstants.slateText), + _divider(), + _cell('₹1,030', 'To collect', ColorConstants.acceptGreen), + ], + ), + ), + SizedBox(height: 14.h), + Row( + children: [ + Icon(Icons.warehouse_rounded, size: 13.sp, color: _hubTeal), + SizedBox(width: 6.w), + Text( + 'HUB', + style: TextStyle( + fontSize: 10.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.6, + color: _hubTeal, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(width: 7.w), + for (var i = 0; i < 4; i++) ...[ + Container( + width: 14.w, + height: 2, + color: ColorConstants.borderStrong, + ), + Container( + width: 7.w, + height: 7.w, + decoration: BoxDecoration( + color: ColorConstants.primary, + shape: BoxShape.circle, + ), + ), + ], + Container( + width: 14.w, + height: 2, + color: ColorConstants.borderStrong, + ), + Icon(Icons.warehouse_rounded, size: 13.sp, color: _hubTeal), + ], + ), + ], + ), + ); + } + + Widget _cell(String value, String label, Color accent) => Expanded( + child: Column( + children: [ + Text( + value, + style: TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 1.h), + Text(label, style: _artMeta(9)), + ], + ), + ); + + Widget _divider() => + Container(width: 1, height: 24.h, color: ColorConstants.borderSubtle); +} + +/// Slide 2 — the list, in order, with one done and one skipped. +class _StopsArt extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _ArtFrame( + child: Column( + children: [ + _row( + 1, + 'Sri Balaji Stores', + 'Done', + ColorConstants.acceptGreen, + Icons.check_rounded, + ), + SizedBox(height: 9.h), + _row( + 2, + 'Anand Traders', + 'Skipped', + ColorConstants.warning, + Icons.redo_rounded, + ), + SizedBox(height: 9.h), + _row(3, 'Kumar Textiles', 'Next', ColorConstants.primary, null), + SizedBox(height: 9.h), + _row(4, 'MS Enterprises', null, ColorConstants.borderStrong, null), + ], + ), + ); + } + + Widget _row( + int n, + String name, + String? status, + Color accent, + IconData? badge, + ) { + final pending = status == null; + return Row( + children: [ + Container( + width: 20.w, + height: 20.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: pending ? ColorConstants.pureSurface : accent, + shape: BoxShape.circle, + border: Border.all(color: accent, width: 1.8), + ), + child: badge != null + ? Icon(badge, size: 11.sp, color: Colors.white) + : Text( + '$n', + style: TextStyle( + fontSize: 10.sp, + fontWeight: FontWeight.w800, + color: pending + ? ColorConstants.secondaryText + : Colors.white, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + SizedBox(width: 10.w), + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + color: pending + ? ColorConstants.secondaryText + : ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + if (status != null) + Text( + status, + style: TextStyle( + fontSize: 10.5.sp, + fontWeight: FontWeight.w800, + color: accent, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ); + } +} + +/// Slide 3 — what leaves the stop with you, and where it ends up. +class _HubArt extends StatelessWidget { + @override + Widget build(BuildContext context) { + return _ArtFrame( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'COLLECT AT THIS STOP', + style: TextStyle( + fontSize: 9.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.9, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 11.h), + _line( + Icons.inventory_2_rounded, + '3 parcels', + ColorConstants.pickupAccent, + ), + SizedBox(height: 9.h), + _line( + Icons.payments_rounded, + '₹340 cash', + ColorConstants.acceptGreen, + ), + SizedBox(height: 9.h), + _line( + Icons.photo_camera_rounded, + 'Photo proof', + ColorConstants.slateText, + ), + SizedBox(height: 14.h), + Container(height: 1, color: ColorConstants.borderSubtle), + SizedBox(height: 13.h), + Row( + children: [ + Container( + width: 22.w, + height: 22.w, + alignment: Alignment.center, + decoration: BoxDecoration( + color: _hubTeal.withValues(alpha: 0.14), + shape: BoxShape.circle, + border: Border.all(color: _hubTeal, width: 1.5), + ), + child: Icon( + Icons.warehouse_rounded, + size: 12.sp, + color: _hubTeal, + ), + ), + SizedBox(width: 9.w), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'RETURN · HUB', + style: TextStyle( + fontSize: 10.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.7, + color: _hubTeal, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 2.h), + Text('Hand everything over', style: _artMeta(10.5)), + ], + ), + ), + ], + ), + ], + ), + ); + } + + Widget _line(IconData icon, String text, Color accent) => Row( + children: [ + Icon(icon, size: 15.sp, color: accent), + SizedBox(width: 9.w), + Text( + text, + style: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w800, + 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 new file mode 100644 index 0000000..4cee272 --- /dev/null +++ b/lib/views/offline/offline_banner.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.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'; + +/// ───────────────────────────────────────────────────────────────────────── +/// OFFLINE, WITHOUT TAKING THE JOB AWAY +/// +/// ── What this replaces ── +/// +/// Losing signal used to lay an opaque full-screen page over the entire app: +/// `Stack(children: [baseChild, const OfflinePage()])`. Not a warning — a +/// replacement. +/// +/// A rider standing at a doorstep in a basement, a lift, a stairwell or a rural +/// lane — which is where Indian first-mile delivery actually happens — lost the +/// address, the customer's name, their phone number, the parcel count and the +/// OTP field. He was holding a parcel and could not see who it was for. The app +/// then told him it was "retrying automatically every 5 seconds", which is not +/// something he can act on while a customer waits. +/// +/// ── Why a strip is the right shape ── +/// +/// Everything the rider needs at the door was already fetched. Connectivity is +/// needed to *report* the stop, not to work it. So the honest message is not +/// "you are offline" but "keep going, this will sync" — and that is a line of +/// text, not a screen. +/// +/// The full-screen page survives for the one case where it is truthful: a cold +/// start with no cached session, where there genuinely is nothing to show. +/// +/// ── Sizing ── +/// +/// Deliberately thin, and it does **not** overlay the content — [OfflineScope] +/// gives it its own row above the app, so it can never cover a field the rider +/// is typing into. The one thing worse than a blocker is a floating bar sitting +/// on top of the OTP box. +/// ───────────────────────────────────────────────────────────────────────── +class OfflineBanner extends StatelessWidget { + const OfflineBanner({super.key}); + + @override + Widget build(BuildContext context) { + return Semantics( + liveRegion: true, + label: 'Offline. Your changes will sync when you are back online.', + excludeSemantics: true, + child: Material( + color: ColorConstants.warning, + child: SafeArea( + bottom: false, + child: Padding( + padding: EdgeInsets.fromLTRB(16.w, 7.h, 16.w, 7.h), + child: Row( + children: [ + Icon( + Icons.cloud_off_rounded, + size: 15.sp, + color: ColorConstants.onAccent, + ), + SizedBox(width: 9.w), + Expanded( + child: Text( + // States what is true and what happens next. "You're + // offline" alone tells a rider mid-stop nothing he can use. + 'Offline · Changes will sync when you’re back online', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: MilerType.micro + .on(ColorConstants.onAccent) + .semibold, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Puts [OfflineBanner] above the app rather than over it. +/// +/// A `Column` rather than a `Stack` on purpose: the strip takes its own height +/// out of the layout, so nothing underneath is ever covered. The app simply +/// starts a few points lower while the connection is down. +class OfflineScope extends StatelessWidget { + final Widget child; + final bool offline; + + const OfflineScope({super.key, required this.child, required this.offline}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + AnimatedSize( + duration: DesignConstants.motionState, + curve: Curves.easeOutCubic, + alignment: Alignment.bottomCenter, + 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 new file mode 100644 index 0000000..5c21b5d --- /dev/null +++ b/lib/views/offline/offline_page.dart @@ -0,0 +1,241 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; + +import 'package:miler/helpers/app_bootstrap.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'; + +class OfflinePage extends StatefulWidget { + const OfflinePage({super.key}); + + @override + State createState() => _OfflinePageState(); +} + +class _OfflinePageState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _pulse; + + bool _checking = false; + Timer? _autoRetry; + + @override + void initState() { + super.initState(); + + _pulse = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1800), + )..repeat(reverse: true); + + // Automatically retry every 5 seconds. + _autoRetry = Timer.periodic(const Duration(seconds: 5), (_) { + if (mounted && !_checking) { + _handleRetry(); + } + }); + } + + @override + void dispose() { + _pulse.dispose(); + _autoRetry?.cancel(); + super.dispose(); + } + + Future _handleRetry() async { + if (_checking) return; + + setState(() => _checking = true); + + try { + final results = await Connectivity().checkConnectivity(); + + final isOnline = + results.isNotEmpty && + results.any((r) => r != ConnectivityResult.none); + + if (isOnline) { + Get.offAll(() => const AppBootstrap()); + return; + } + + if (mounted) { + AppFeedback.info(context, "Still offline. We'll keep checking."); + } + } finally { + if (mounted) { + setState(() => _checking = false); + } + } + } + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + + return Scaffold( + backgroundColor: const Color(0xffF7F8FC), + body: SafeArea( + child: Stack( + children: [ + Positioned( + bottom: -size.height * .10, + left: -40, + right: -40, + child: Container( + height: size.height * .25, + decoration: const BoxDecoration( + color: ColorConstants.neutralLight, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(250), + topRight: Radius.circular(250), + ), + ), + ), + ), + + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: TweenAnimationBuilder( + duration: const Duration(milliseconds: 700), + tween: Tween(begin: 0, end: 1), + curve: Curves.easeOut, + builder: (context, value, child) { + return Opacity( + opacity: value, + child: Transform.translate( + offset: Offset(0, (1 - value) * 30), + child: child, + ), + ); + }, + child: Container( + padding: const EdgeInsets.all(28), + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + boxShadow: [ + BoxShadow( + color: ColorConstants.slateText.withValues(alpha: .05), + blurRadius: 25, + offset: const Offset(0, 12), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _pulse, + builder: (_, child) { + final t = Curves.easeInOut.transform(_pulse.value); + + return Container( + width: 130, + height: 130, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ColorConstants.primary.withValues(alpha: .08), + boxShadow: [ + BoxShadow( + color: ColorConstants.primary.withValues(alpha: + .10 + (.15 * t), + ), + blurRadius: 25 + (18 * t), + spreadRadius: 2 + (8 * t), + ), + ], + ), + child: child, + ); + }, + child: Icon( + Icons.wifi_off_rounded, + color: ColorConstants.primary, + size: 68, + ), + ), + + const SizedBox(height: 28), + + Text("You're offline", style: MilerType.hero), + + const SizedBox(height: 12), + + Text( + "We can't reach Miler right now. Your shift will " + "load as soon as you're back online.", + textAlign: TextAlign.center, + style: MilerType.caption, + ), + + const SizedBox(height: 28), + + SizedBox( + width: double.infinity, + height: ButtonSizes.primary, + child: ElevatedButton( + onPressed: _checking ? null : _handleRetry, + style: ElevatedButton.styleFrom( + elevation: 0, + backgroundColor: ColorConstants.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + ButtonSizes.radius, + ), + ), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + child: _checking + ? const SizedBox( + key: ValueKey("loading"), + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : Text( + "Try again", + key: const ValueKey("retry"), + style: MilerType.buttonLabel( + ButtonSizes.primary, + ), + ), + ), + ), + ), + + const SizedBox(height: 16), + + Text( + "Checking every few seconds", + style: MilerType.label, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/onboardscreens/Creat_mpin.dart b/lib/views/onboardscreens/Creat_mpin.dart new file mode 100644 index 0000000..469d933 --- /dev/null +++ b/lib/views/onboardscreens/Creat_mpin.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.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/controllers/auth.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/onboardscreens/Mpin.dart'; +import 'package:miler/views/onboardscreens/auth_scaffold.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// CREATE MPIN — step 3 of 3. +/// +/// ── What changed and why ── +/// +/// **A mismatch now says so.** Previously the CTA was simply disabled while +/// `isMpinMatched` was false, and nothing on screen explained why: the rider +/// entered four digits, entered four more, and the button stayed dead. Two +/// non-matching 4-digit PINs look identical when both are rendered as `••••`, so +/// there was no way to find the mistake by looking. The confirm row now turns red +/// and prints the reason, and it clears itself so he can retype rather than +/// hunting for which box is wrong. +/// +/// **Weak PINs are refused.** `1111` and `1234` were accepted, on a credential +/// that unlocks a rider's account and their COD cash reconciliation. Both are in +/// every attacker's first ten guesses — and `1234` is this app's own documented +/// master PIN, so a rider could set a PIN that collides with it. +/// +/// **The decorative shield tile is gone**, and the two 4-digit rows are drawn by +/// the shared [AuthCodeRow] instead of a private copy — this screen and the +/// unlock screen were rendering the same control two different ways. +/// ───────────────────────────────────────────────────────────────────────── +class CreateMpin extends GetResponsiveView { + CreateMpin({super.key}); + + @override + Widget builder() { + return const _CreateMpinBody(); + } +} + +class _CreateMpinBody extends StatefulWidget { + const _CreateMpinBody(); + + @override + State<_CreateMpinBody> createState() => _CreateMpinBodyState(); +} + +class _CreateMpinBodyState extends State<_CreateMpinBody> { + final AuthController _auth = Get.put(AuthController()); + + final List _newMpinControllers = List.generate( + 4, + (_) => TextEditingController(), + ); + final List _confirmMpinControllers = List.generate( + 4, + (_) => TextEditingController(), + ); + + final List _newFocusNodes = List.generate(4, (_) => FocusNode()); + final List _confirmFocusNodes = List.generate( + 4, + (_) => FocusNode(), + ); + + bool isLoading = false; + String? _error; + + /// PINs that are trivially guessable. `1234` is also this build's master PIN, + /// so letting a rider choose it would collide with the override path. + static const _tooWeak = { + '1234', + '4321', + '0000', + '1111', + '2222', + '3333', + '4444', + '5555', + '6666', + '7777', + '8888', + '9999', + '1122', + '2580', + }; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _newFocusNodes[0].requestFocus(), + ); + } + + @override + void dispose() { + for (var c in [..._newMpinControllers, ..._confirmMpinControllers]) { + c.dispose(); + } + for (var f in [..._newFocusNodes, ..._confirmFocusNodes]) { + f.dispose(); + } + super.dispose(); + } + + String _pin(List c) => c.map((e) => e.text).join(); + + bool get _newFilled => _newMpinControllers.every((c) => c.text.isNotEmpty); + bool get _confirmFilled => + _confirmMpinControllers.every((c) => c.text.isNotEmpty); + bool get isMpinMatched => + _pin(_newMpinControllers) == _pin(_confirmMpinControllers); + bool get isAllFilled => _newFilled && _confirmFilled; + + void _onChanged( + String value, + int index, + List controllers, + List nodes, + ) { + if (value.isNotEmpty && index < 3) { + nodes[index + 1].requestFocus(); + } else if (value.isEmpty && index > 0) { + nodes[index - 1].requestFocus(); + } + + final isGroupFilled = controllers.every((c) => c.text.isNotEmpty); + // Hand off from the first row to the second automatically; the old version + // did this too and it is the right behaviour. + if (controllers == _newMpinControllers && isGroupFilled) { + _confirmFocusNodes[0].requestFocus(); + } + + setState(() { + _error = null; + // Judge only once both rows are complete — grading a half-typed + // confirmation would flash a mismatch on every single keystroke. + if (controllers == _confirmMpinControllers && isGroupFilled) { + FocusScope.of(context).unfocus(); + _error = _validate(); + } + }); + } + + /// Null when the pair is acceptable. + String? _validate() { + final pin = _pin(_newMpinControllers); + if (!isMpinMatched) + return 'Those PINs do not match. Re-enter the second one.'; + if (_tooWeak.contains(pin)) { + return 'That PIN is too easy to guess. Avoid runs and repeats.'; + } + return null; + } + + void _clearConfirm() { + for (final c in _confirmMpinControllers) { + c.clear(); + } + _confirmFocusNodes[0].requestFocus(); + } + + Future _save() async { + final problem = _validate(); + if (problem != null) { + setState(() => _error = problem); + // A mismatch is almost always a typo in the confirmation, so clear that + // row and put the caret in it. Making him find the wrong digit himself, + // through four dots, is not a task anyone can do. + if (!isMpinMatched) _clearConfirm(); + return; + } + + setState(() => isLoading = true); + final ok = await _auth.setPin(_pin(_newMpinControllers)); + if (!mounted) return; + setState(() => isLoading = false); + if (ok) { + openScreen(context, Mpin()); + } else { + setState(() => _error = 'Could not save your PIN. Please try again.'); + } + } + + @override + Widget build(BuildContext context) { + final mismatch = _error != null && isAllFilled; + + return AuthScaffold( + onBack: () => Get.back(), + // The last of the two counted screens — see the note in `otp_page`. + step: 2, + totalSteps: 2, + // Compact: this screen focuses its first box on the first frame, so the + // full block would render once and then animate itself away. + banner: const AuthBrandBanner(compact: true), + title: 'Create your MPIN', + subtitle: + 'Four digits, used to unlock the app from now on. Do not share it — ' + 'it also signs off the cash you collect.', + footer: AuthPrimaryButton( + label: 'Save & continue', + loading: isLoading, + onPressed: isLoading || !isAllFilled ? null : _save, + ), + children: [ + Text('NEW MPIN', style: AuthType.fieldLabel), + SizedBox(height: 10.h), + AuthCodeRow( + controllers: _newMpinControllers, + nodes: _newFocusNodes, + obscure: true, + onChanged: (v, i) => + _onChanged(v, i, _newMpinControllers, _newFocusNodes), + ), + SizedBox(height: 26.h), + Row( + children: [ + Text('CONFIRM MPIN', style: AuthType.fieldLabel), + const Spacer(), + // A quiet, non-alarming confirmation that the two rows agree — + // the only feedback available when both read as four dots. + if (isAllFilled && _error == null) + Row( + children: [ + Icon( + Icons.check_circle_rounded, + size: 14.sp, + color: ColorConstants.acceptGreen, + ), + SizedBox(width: 5.w), + Text( + 'Matches', + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w800, + color: ColorConstants.acceptGreen, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ], + ), + SizedBox(height: 10.h), + AuthCodeRow( + controllers: _confirmMpinControllers, + nodes: _confirmFocusNodes, + obscure: true, + hasError: mismatch, + onChanged: (v, i) => + _onChanged(v, i, _confirmMpinControllers, _confirmFocusNodes), + ), + AuthInlineError(message: _error), + ], + ); + } +} diff --git a/lib/views/onboardscreens/Mpin.dart b/lib/views/onboardscreens/Mpin.dart new file mode 100644 index 0000000..60dfd6a --- /dev/null +++ b/lib/views/onboardscreens/Mpin.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.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/controllers/auth.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// UNLOCK — the returning rider's front door. +/// +/// Deliberately **not** given a step indicator. This is the screen a rider sees +/// every morning, and it is one screen long; labelling it "step 1 of 3" would +/// imply two more to go. The step rail belongs to the one-time sign-up flow. +/// +/// ── What changed and why ── +/// +/// **It says whose account it is.** A shared or handed-down phone is normal in +/// this fleet, and the screen gave no clue which rider's account was about to be +/// unlocked. It now shows the name on the session, which is also the fastest way +/// to notice you are on the wrong one. +/// +/// **The boxes match the rest of the flow.** This screen drew its four boxes at +/// 70×70, radius 16, filled grey with a *transparent* border that only appeared +/// on focus — so an unfocused box had no edge at all, while the create-MPIN +/// screen two taps earlier drew the same control white with a visible border. +/// Both now use the shared [AuthCodeRow]. +/// +/// **"Access your account securely" is gone.** It is filler: the rider knows what +/// a PIN box is, and no screen has ever been made more secure by claiming to be. +/// ───────────────────────────────────────────────────────────────────────── +class Mpin extends GetResponsiveView { + Mpin({super.key}); + + @override + Widget builder() { + return const _MpinView(); + } +} + +class _MpinView extends StatefulWidget { + const _MpinView(); + + @override + State<_MpinView> createState() => _MpinViewState(); +} + +class _MpinViewState extends State<_MpinView> { + final AuthController _auth = Get.put(AuthController()); + final List _mpinControllers = List.generate( + 4, + (_) => TextEditingController(), + ); + final List _focusNodes = List.generate(4, (_) => FocusNode()); + + bool isVerifying = false; + bool _rejected = false; + String _riderName = ''; + + @override + void initState() { + super.initState(); + _loadRider(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNodes[0].requestFocus(); + }); + } + + Future _loadRider() async { + try { + final prefs = await SharedPreferences.getInstance(); + final name = (prefs.getString('user_name') ?? '').trim(); + if (mounted && name.isNotEmpty) { + setState(() => _riderName = name.split(' ').first); + } + } catch (_) { + // Non-essential: the screen works without a name. + } + } + + @override + void dispose() { + for (var c in _mpinControllers) { + c.dispose(); + } + for (var f in _focusNodes) { + f.dispose(); + } + super.dispose(); + } + + bool get _isComplete => _mpinControllers.every((c) => c.text.isNotEmpty); + + void _onMpinChange(String value, int index) { + if (value.isNotEmpty && index < 3) { + _focusNodes[index + 1].requestFocus(); + } else if (value.isEmpty && index > 0) { + _focusNodes[index - 1].requestFocus(); + } + + setState(() { + if (_rejected) _rejected = false; + }); + + if (_isComplete) { + FocusScope.of(context).unfocus(); + _submitMpin(); + } + } + + /// Wired to [AuthController.onPinRetry] — the controller calls this after it + /// has reported a bad PIN, so the boxes clear and the caret comes back. + void _clearMpinAndFocus() { + for (final c in _mpinControllers) { + c.clear(); + } + _focusNodes[0].requestFocus(); + if (mounted) setState(() => _rejected = true); + } + + Future _submitMpin() async { + if (!mounted || isVerifying) return; + _auth.onPinRetry = _clearMpinAndFocus; + setState(() => isVerifying = true); + + final ok = await _auth.verifyPinWithServer( + _mpinControllers.map((c) => c.text).join(), + ); + + if (!mounted) return; + setState(() => isVerifying = false); + + 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()); + } + } + + @override + Widget build(BuildContext context) { + return AuthScaffold( + onBack: () => Get.back(), + // The strip, not the full block, and fixed rather than adaptive: this + // screen focuses its first box on the first frame, and it is the one the + // rider opens every morning. See [AuthBrandBanner]. + // + // The small corner mark that used to sit here is gone with it — two + // Doormile logos on one screen is one more than the screen needs. + banner: const AuthBrandBanner(compact: true), + title: _riderName.isEmpty + ? 'Enter your MPIN' + : 'Welcome back, $_riderName', + subtitle: 'Enter your 4-digit MPIN to unlock.', + footer: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AuthPrimaryButton( + label: 'Unlock', + icon: Icons.lock_open_rounded, + loading: isVerifying, + onPressed: isVerifying || !_isComplete ? null : _submitMpin, + ), + SizedBox(height: 6.h), + AuthTextAction( + label: 'Forgot MPIN?', + onPressed: isVerifying + ? null + : () async { + await _auth.sendOtp(); + if (mounted) openScreen(context, OtpPage()); + }, + ), + ], + ), + children: [ + AuthCodeRow( + controllers: _mpinControllers, + nodes: _focusNodes, + obscure: true, + hasError: _rejected, + onChanged: _onMpinChange, + ), + // The controller owns the failure message (it distinguishes a wrong PIN + // from a dead session), so this only marks that the attempt was + // rejected and the boxes were reset — otherwise they empty themselves + // with no stated reason. + AuthInlineError( + message: _rejected ? 'Incorrect MPIN. Try again.' : null, + ), + ], + ); + } +} diff --git a/lib/views/onboardscreens/Sign_in.dart b/lib/views/onboardscreens/Sign_in.dart new file mode 100644 index 0000000..696e15e --- /dev/null +++ b/lib/views/onboardscreens/Sign_in.dart @@ -0,0 +1,443 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/Mpin.dart'; +import 'package:miler/views/onboardscreens/auth_scaffold.dart'; +import 'package:miler/controllers/auth.dart'; +import 'package:flutter/gestures.dart'; +import 'package:miler/views/Dashboard/profile/informations/terms_condition.dart'; +import 'package:miler/providers/notifications/notificationservce.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'; + +class LoginController extends GetxController { + var isChecked = true.obs; + final AuthController auth = Get.put(AuthController()); +} + +/// ───────────────────────────────────────────────────────────────────────── +/// SIGN IN — step 1 of 3. +/// +/// ── What changed and why ── +/// +/// **The logo stopped performing.** This screen led with a 104px mark wrapped in +/// a `repeat(reverse: true)` controller that breathed a brand-coloured glow and +/// floated it up and down, forever, at 60fps — behind which sat a 1100ms +/// staggered cascade that faded in eight elements one at a time. So the first +/// second of the app was spent watching a logo pulse while the field the rider +/// needed was still arriving, and the animation kept running afterwards under +/// the keyboard where nobody could see it. No serious app animates its own logo +/// on a login screen; the mark is now the same small [MilerBrandMark] the rest +/// of the app uses, sitting quietly in the corner. +/// +/// **The question became the heading.** "Welcome back" is a greeting, not an +/// instruction, and it was followed by a sentence explaining what the field +/// below it obviously did. Uber asks "What's your mobile number?" because the +/// heading and the task are then the same thing, and the screen needs no second +/// line of prose to explain itself. +/// +/// **The error stopped crying wolf.** The old validator showed +/// "Enter a valid 10-digit number" as soon as `text.isNotEmpty` and the number +/// was not yet valid — i.e. from the first keystroke, for eight straight +/// keystrokes, on every correct entry. An error that fires while you are still +/// typing the right answer trains people to ignore errors. It now waits until +/// there are ten digits to judge (or until Continue is pressed). +/// ───────────────────────────────────────────────────────────────────────── +class SignIn extends StatefulWidget { + const SignIn({super.key}); + + @override + State createState() => _SignInState(); +} + +class _SignInState extends State { + final LoginController controller = Get.put(LoginController()); + final TextEditingController phoneController = TextEditingController(); + final FocusNode _phoneFocusNode = FocusNode(); + + bool isPhoneValid = false; + bool isLoading = false; + + /// Errors appear once the rider has had a fair chance to be right — ten + /// digits typed, or Continue pressed — never mid-entry. + bool _showError = false; + String? _errorText; + + // Held as fields so they can be disposed (an inline recognizer leaks). + late final TapGestureRecognizer _termsRecognizer = TapGestureRecognizer() + ..onTap = () => openSheet(context, TermsCondition()); + late final TapGestureRecognizer _privacyRecognizer = TapGestureRecognizer() + ..onTap = () => openSheet(context, TermsCondition()); + + @override + void initState() { + super.initState(); + _phoneFocusNode.addListener(() { + if (mounted) setState(() {}); + }); + // ── No autofocus ── + // + // This used to open the keyboard on the first frame, which on a screen with + // a brand block means the rider never sees it: the banner halves itself the + // moment the keyboard appears, so the app would animate its own masthead + // away before he had looked at it. Sign-in happens about once per install, + // so the tap it costs him is paid once; the alternative is a brand moment + // that exists in the code and never on the screen. + WidgetsBinding.instance.addPostFrameCallback((_) { + NotificationServce.initialize(context); + }); + } + + bool _validatePhoneNumber(String number) { + final RegExp regExp = RegExp(r'^[6-9]\d{9}$'); + return regExp.hasMatch(number); + } + + void _onPhoneChanged(String value) { + setState(() { + isPhoneValid = _validatePhoneNumber(value); + // Judge only a complete entry. A 10-digit number that still fails the + // pattern started with 0–5, which is worth saying out loud. + if (value.length == 10) { + _showError = !isPhoneValid; + _errorText = isPhoneValid + ? null + : 'Indian mobile numbers start with 6, 7, 8 or 9.'; + } else { + _showError = false; + _errorText = null; + } + }); + if (value.length == 10 && isPhoneValid) { + FocusScope.of(context).unfocus(); + } + } + + Future _onContinue() async { + if (!isPhoneValid) { + setState(() { + _showError = true; + _errorText = 'Enter your 10-digit mobile number.'; + }); + return; + } + + setState(() { + isLoading = true; + _showError = false; + _errorText = null; + }); + + final decision = await controller.auth.precheckPhone(phoneController.text); + if (!mounted) return; + setState(() => isLoading = false); + + if (decision == AuthNext.otp) { + await controller.auth.sendOtp(phoneController.text); + if (!mounted) return; + openScreen(context, OtpPage()); + } else if (decision == AuthNext.verifyPin) { + openScreen(context, Mpin()); + } else { + // notRegistered / error. Reported on the screen rather than in a snackbar + // under the keyboard, where the old version put it. + setState(() { + _showError = true; + _errorText = + 'We could not sign you in with that number. Check it and try ' + 'again, or contact your hub manager.'; + }); + } + } + + @override + void dispose() { + _phoneFocusNode.dispose(); + phoneController.dispose(); + _termsRecognizer.dispose(); + _privacyRecognizer.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GetBuilder( + builder: (_) => AuthScaffold( + // ── No step rail on this screen ── + // + // It used to say "STEP 1 OF 3", which turned signing in into a form + // with a length. Most of the people who open this screen are not + // signing up at all — a returning rider types his number and, on the + // very next screen, his MPIN, which carries no counter. So the flow + // announced itself as three steps long and then stopped counting, + // which is worse than never having counted. + // + // The rail survives where it is honestly true: the code and PIN + // screens of a genuine first-time sign-up, renumbered to 1 and 2 of 2 + // so the rider still meets a counter that starts at the beginning. + // The brand block replaces the small corner mark this screen used to + // carry. See [AuthBrandBanner] for why this screen gets one and the + // rest of the flow does not. + banner: const AuthBrandBanner(), + // "What's your mobile number?" was a good heading and this is a + // shorter one. The screen has a banner above it now, so the title is no + // longer the first thing on the page and does not have to introduce it + // — it only has to name the task. + title: 'Enter your number', + footer: _buildFooter(), + children: [ + // The standalone 'MOBILE NUMBER' eyebrow is gone: the field carries + // its own label in the notch of its border now, which is the same + // information in one element instead of two. + _buildPhoneField(context), + AuthInlineError(message: _showError ? _errorText : null), + ], + ), + ); + } + + /// ── The number field ── + /// + /// Was a hand-rolled `AnimatedContainer` holding a borderless `TextField`, + /// with the label printed as a separate eyebrow above it. That is two + /// elements and about eighty lines to reproduce what an `OutlineInputBorder` + /// does natively — including the notch the label sits in, which is the part + /// that lets the label stay visible *while* the rider types. An eyebrow above + /// the box scrolls away from its field; a notched label never can. + /// + /// The border colour is the only state signal: neutral at rest, brand when + /// focused, red on a judged error. The focus glow is gone with the hand-rolled + /// box — a 2px brand edge already says "here", and a coloured shadow under one + /// field on an otherwise flat screen was the only elevation in the flow. + Widget _buildPhoneField(BuildContext context) { + final focused = _phoneFocusNode.hasFocus; + final error = _showError && _errorText != null; + + final Color edge = error + ? ColorConstants.errorRed + : focused + ? ColorConstants.primary + : ColorConstants.borderStrong; + + OutlineInputBorder side(Color colour, double width) => OutlineInputBorder( + borderRadius: BorderRadius.circular(ButtonSizes.radius.r), + borderSide: BorderSide(color: colour, width: width), + ); + + return TextField( + controller: phoneController, + focusNode: _phoneFocusNode, + keyboardType: TextInputType.phone, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + maxLength: 10, + autofillHints: const [AutofillHints.telephoneNumberNational], + cursorColor: ColorConstants.primary, + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + onChanged: _onPhoneChanged, + onSubmitted: (_) { + if (isPhoneValid && !isLoading) _onContinue(); + }, + decoration: InputDecoration( + counterText: '', + filled: true, + fillColor: ColorConstants.pureSurface, + labelText: 'Mobile number', + // Always floated, so the field reads as labelled before it is touched + // rather than showing a placeholder that vanishes on the first digit. + floatingLabelBehavior: FloatingLabelBehavior.always, + labelStyle: TextStyle( + fontSize: 13.sp, + fontWeight: FontWeight.w700, + letterSpacing: -0.1, + color: error + ? ColorConstants.errorRed + : focused + ? ColorConstants.primary + : ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + hintText: '00000 00000', + hintStyle: TextStyle( + color: ColorConstants.secondaryText, + fontSize: 18.sp, + fontWeight: FontWeight.w600, + letterSpacing: 1.2, + fontFamily: FontConstants.fontFamily, + ), + contentPadding: EdgeInsets.symmetric(vertical: 18.h), + // ── Country prefix ── + // + // No chevron. Instamart's field has one because it serves more than one + // country; Doormile does not, and an affordance that opens nothing is a + // promise the screen cannot keep. + prefixIcon: Padding( + padding: EdgeInsets.only(left: 14.w, right: 11.w), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const _IndiaFlag(), + SizedBox(width: 8.w), + Text( + '+91', + style: TextStyle( + fontSize: 17.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(width: 11.w), + Container( + width: 1, + height: 24.h, + color: ColorConstants.borderStrong, + ), + ], + ), + ), + prefixIconConstraints: const BoxConstraints(minWidth: 0, minHeight: 0), + suffixIcon: isPhoneValid + ? Padding( + padding: EdgeInsets.only(right: 14.w), + child: Icon( + Icons.check_circle_rounded, + size: 20.sp, + color: ColorConstants.acceptGreen, + ), + ) + : null, + suffixIconConstraints: const BoxConstraints(minWidth: 0, minHeight: 0), + border: side(edge, 1.5), + enabledBorder: side(ColorConstants.borderStrong, 1.5), + focusedBorder: side(ColorConstants.primary, 2), + errorBorder: side(ColorConstants.errorRed, 2), + focusedErrorBorder: side(ColorConstants.errorRed, 2), + ), + ); + } + + Widget _buildFooter() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AuthPrimaryButton( + label: 'Continue', + loading: isLoading, + // Enabled whenever it is not mid-request, so pressing it on an + // incomplete number *says why* instead of doing nothing. A dead + // button gives the rider no way to find out what is wrong. + onPressed: isLoading ? null : _onContinue, + ), + SizedBox(height: 12.h), + RichText( + textAlign: TextAlign.center, + text: TextSpan( + style: TextStyle( + fontSize: 11.5.sp, + fontWeight: FontWeight.w500, + color: ColorConstants.secondaryText, + height: 1.5, + fontFamily: FontConstants.fontFamily, + ), + children: [ + const TextSpan(text: 'By continuing you agree to our '), + TextSpan( + text: 'Terms of Service', + style: TextStyle( + color: ColorConstants.slateText, + fontWeight: FontWeight.w800, + decoration: TextDecoration.underline, + decorationColor: ColorConstants.borderStrong, + fontFamily: FontConstants.fontFamily, + ), + recognizer: _termsRecognizer, + ), + const TextSpan(text: ' and '), + TextSpan( + text: 'Privacy Policy', + style: TextStyle( + color: ColorConstants.slateText, + fontWeight: FontWeight.w800, + decoration: TextDecoration.underline, + decorationColor: ColorConstants.borderStrong, + fontFamily: FontConstants.fontFamily, + ), + recognizer: _privacyRecognizer, + ), + const TextSpan(text: '.'), + ], + ), + ), + ], + ); + } +} + +/// ── The flag is drawn, not typed ── +/// +/// 🇮🇳 renders as a grey "IN" tofu box on a fair number of Android builds, which +/// is exactly why the flag was taken off this field once before and replaced +/// with a bare "+91". Three bars and a ring cost nothing, render identically +/// everywhere, and give the prefix the thing it was missing: something to +/// recognise before reading. +/// +/// The three hex values are the flag's own — saffron, navy, India green. They +/// are deliberately not tokens: a national flag is a literal, not a semantic +/// role, and putting it in the palette would invite it to be reused as one. +class _IndiaFlag extends StatelessWidget { + const _IndiaFlag(); + + static const Color _saffron = Color(0xFFFF9933); + static const Color _navy = Color(0xFF06038D); + static const Color _green = Color(0xFF138808); + + @override + Widget build(BuildContext context) { + // Decoration beside a "+91" that already says which country this is. + return ExcludeSemantics( + child: Container( + width: 21.w, + height: 15.w, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(3.r), + border: Border.all(color: ColorConstants.borderStrong, width: 0.5), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(2.5.r), + child: Column( + children: [ + const Expanded(child: ColoredBox(color: _saffron, child: SizedBox.expand())), + Expanded( + child: Container( + color: Colors.white, + alignment: Alignment.center, + child: Container( + width: 4.w, + height: 4.w, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: _navy, width: 0.9), + ), + ), + ), + ), + 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 new file mode 100644 index 0000000..6084f4c --- /dev/null +++ b/lib/views/onboardscreens/auth_scaffold.dart @@ -0,0 +1,986 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.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/design_constants.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; + +/// ═════════════════════════════════════════════════════════════════════════ +/// ONBOARDING DESIGN SYSTEM +/// +/// Sign-in was five screens that each invented their own layout: a 104px logo +/// with a forever-pulsing glow, then a 64px tinted SMS tile, then a 64px tinted +/// lock tile, then a 64px tinted shield tile, then a 120px logo. Same flow, +/// five different first impressions, and the rider could not tell how far +/// through it he was on any of them. +/// +/// What Uber, Amazon and Stripe all do with a phone → code → PIN flow is the +/// opposite: **one frame, repeated.** Back arrow, where-am-I, a large question, +/// the single input that answers it, one button. The frame never moves, so the +/// only thing that changes between steps is the question — and that is exactly +/// what should be changing. +/// +/// This file is that frame. Three rules it enforces: +/// +/// 1. **No decoration above the question.** The tinted icon tiles are gone. An +/// SMS glyph tells the rider nothing that "Enter the 6-digit code" does not, +/// and it pushed the actual content a third of the way down the screen. This +/// is the same call already made on the stop card, where tinted icon tiles +/// lost to plain rows — onboarding was the last place still doing it. +/// +/// 2. **Slate, not brown.** The dashboard is slate-on-white +/// ([ColorConstants.slateText] / [ColorConstants.borderStrong]). Onboarding +/// was built on the M3 tonal leftovers of the red brand seed — +/// `onSurfaceVariant` is **#5C403E, a brown**, and `outlineVariant` is +/// **#E5BDBB, a pink**. Every body line and every input border in the flow +/// was one of those two, so the app opened brown-and-pink and then turned +/// slate-and-white the moment the rider signed in. +/// +/// 3. **Nothing animates forever.** The old sign-in ran a 3.2s glow + +/// float on `repeat(reverse: true)`, and every intro slide ran its own +/// 1.8s pulse — permanent 60fps repaints on screens whose entire job is to +/// let someone type. Entrances here are one-shot and short; see +/// [AuthEntrance]. +/// ═════════════════════════════════════════════════════════════════════════ + +/// Shared type scale. Every heading in the flow is the same size and weight, +/// which is what makes the five screens read as one thing. +abstract final class AuthType { + static TextStyle get title => TextStyle( + fontSize: 27.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.7, + height: 1.15, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ); + + static TextStyle get subtitle => TextStyle( + fontSize: 14.5.sp, + fontWeight: FontWeight.w500, + height: 1.45, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ); + + static TextStyle get fieldLabel => TextStyle( + fontSize: 12.sp, + fontWeight: FontWeight.w800, + letterSpacing: 0.5, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ); + + static TextStyle get meta => TextStyle( + fontSize: 12.5.sp, + fontWeight: FontWeight.w600, + height: 1.45, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ); +} + +/// One-shot fade + rise for a screen's content. +/// +/// Replaces sign-in's 1100ms per-element cascade, which staggered eight items +/// at 80ms apart and so took over a second to finish — on a screen where the +/// keyboard is already up and the rider is trying to type. A single short move +/// on the whole block reads as "the screen arrived" without making anyone wait +/// for it, and it cannot be left running. +class AuthEntrance extends StatefulWidget { + final Widget child; + const AuthEntrance({super.key, required this.child}); + + @override + State createState() => _AuthEntranceState(); +} + +class _AuthEntranceState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 320), + ); + late final Animation _a = CurvedAnimation( + parent: _c, + curve: Curves.easeOutCubic, + ); + + @override + void initState() { + super.initState(); + _c.forward(); + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _a, + builder: (_, child) => Opacity( + opacity: _a.value, + child: Transform.translate( + offset: Offset(0, 12 * (1 - _a.value)), + child: child, + ), + ), + child: widget.child, + ); + } +} + +/// ── Where am I in this flow ── +/// +/// The flow is phone → code → PIN, and none of the three screens said so. A +/// rider who has just typed a code has no way to know whether he is nearly done +/// or has five screens to go, and "how much longer is this" is the single +/// biggest reason people abandon a sign-up half way. +/// +/// The count starts at the *code* screen, not the number screen. Typing a +/// phone number is the door to two different journeys — a returning rider goes +/// straight to an MPIN screen that has no counter and never needed one — so +/// counting from there promised a three-step form to people who had two +/// screens left, and to others who had one. What the rail measures now is the +/// part that is genuinely a multi-screen sign-up. +/// +/// Deliberately a bar and a count rather than dots: dots say "there are three +/// of these" but not which are behind you, and this reuses the segmented-bar +/// language already used for route progress on Bookings. +class AuthSteps extends StatelessWidget { + final int step; + final int total; + + const AuthSteps({super.key, required this.step, this.total = 3}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'STEP $step OF $total', + style: TextStyle( + fontSize: 10.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 1.0, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + SizedBox(height: 7.h), + Row( + children: [ + for (var i = 1; i <= total; i++) ...[ + if (i > 1) SizedBox(width: 5.w), + Expanded( + child: AnimatedContainer( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOut, + height: 4.h, + decoration: BoxDecoration( + color: i <= step + ? ColorConstants.primary + : ColorConstants.borderStrong, + borderRadius: BorderRadius.circular(DesignConstants.radiusFull), + ), + ), + ), + ], + ], + ), + ], + ); + } +} + +/// The one frame every auth screen wears. +/// +/// Content is **top-anchored, not centred**: the question and its input sit +/// high, where they stay put when the keyboard opens, instead of being shoved +/// around by it. The CTA is pinned to the bottom of the remaining space, so it +/// rides just above the keyboard rather than hiding behind it. +class AuthScaffold extends StatelessWidget { + /// Back affordance. Null hides it — used on the first screen of the flow and + /// on any screen there is no going back from. + final VoidCallback? onBack; + + /// 1-based position in the sign-up flow. Null on screens outside it — the + /// returning-user unlock is not "step 1 of 2" of anything, and neither is + /// the number screen that leads to it. + final int? step; + final int totalSteps; + + final String title; + final String? subtitle; + + /// Everything under the title block. + final List children; + + /// Pinned to the bottom, above the keyboard. Usually an [AuthPrimaryButton] + /// plus whatever legal text belongs with it. + final Widget footer; + + /// Optional trailing widget on the top bar (e.g. a brand mark). + final Widget? topRight; + + /// A full-bleed brand block above the content, which then sits on a rounded + /// sheet lifted over it. + /// + /// Null on every screen but the first, and deliberately so — see + /// [AuthBrandBanner]. When null this widget renders exactly the layout it + /// always has, so opting one screen in cannot move the other three. + final Widget? banner; + + const AuthScaffold({ + super.key, + this.onBack, + this.step, + this.totalSteps = 3, + required this.title, + this.subtitle, + required this.children, + required this.footer, + this.topRight, + this.banner, + }); + + @override + Widget build(BuildContext context) { + if (banner != null) return _banneredLayout(context); + + return AnnotatedRegion( + value: SystemUiOverlayStyle.dark.copyWith( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + ), + child: Scaffold( + backgroundColor: ColorConstants.surface, + body: SafeArea( + child: Column( + children: [ + // ── Top bar ── + // A 44dp back target, not a bare 20sp glyph in an IconButton with + // 8px of padding. This is the control a rider hits with a glove on. + if (onBack != null || topRight != null) + Padding( + padding: EdgeInsets.fromLTRB(10.w, 6.h, 16.w, 0), + child: Row( + children: [ + if (onBack != null) + _TapTarget( + onTap: onBack!, + semanticLabel: 'Back', + child: Icon( + Icons.arrow_back_rounded, + size: 22.sp, + color: ColorConstants.slateText, + ), + ) + else + SizedBox(width: 14.w), + const Spacer(), + if (topRight != null) topRight!, + ], + ), + ), + + Expanded( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.fromLTRB(24.w, 0, 24.w, 8.h), + child: AuthEntrance( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 14.h), + if (step != null) ...[ + AuthSteps(step: step!, total: totalSteps), + SizedBox(height: 26.h), + ], + Text(title, style: AuthType.title), + if (subtitle != null) ...[ + SizedBox(height: 9.h), + Text(subtitle!, style: AuthType.subtitle), + ], + SizedBox(height: 30.h), + ...children, + ], + ), + ), + ), + ), + + Padding( + padding: EdgeInsets.fromLTRB(24.w, 4.h, 24.w, 10.h), + child: footer, + ), + ], + ), + ), + ), + ); + } + + /// ── Banner + sheet ── + /// + /// The brand block runs full-bleed to the top of the screen, status bar + /// included, and the content rides on a rounded sheet lifted [_lift] over it + /// so the two read as one card sitting on a coloured ground rather than as + /// two stacked bands. + /// + /// The lift is a `Transform.translate` rather than a `Stack`, so there is + /// exactly one animated height on the screen — the banner's. A Stack would + /// need the banner and the content's top offset to animate in lockstep, and + /// two controllers describing one movement is how they end up disagreeing. + /// The sheet finishes [_lift] short of the bottom as a result, which is + /// invisible: the Scaffold behind it is the same colour. The footer's bottom + /// padding carries that back so the button does not ride high. + Widget _banneredLayout(BuildContext context) { + return AnnotatedRegion( + // Light glyphs: the status bar sits on the brand block, not on the page. + value: SystemUiOverlayStyle.light.copyWith( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + ), + child: Scaffold( + backgroundColor: ColorConstants.pureSurface, + body: Column( + children: [ + // ── The banner has to be told about the keyboard ── + // + // `Scaffold` with `resizeToAvoidBottomInset` (the default, and what + // lifts the footer clear of the keyboard) *consumes* the bottom + // view inset: everything inside `body` sees `viewInsets.bottom == 0` + // whether the keyboard is up or not. So a child that wants to react + // to the keyboard cannot ask the obvious question from in there. + // + // This context is AuthScaffold's own, above the Scaffold, so it + // still carries the real value. Re-providing it to the banner's + // subtree hands the untouched insets back to exactly the one widget + // that needs them, and leaves the Scaffold's own resizing alone. + Stack( + children: [ + MediaQuery(data: MediaQuery.of(context), child: banner!), + + // ── Back rides on the banner ── + // + // The plain layout gives back its own row above the content. + // There is no such row here, and three of the four screens in + // this flow pass an `onBack` — so without this the banner would + // silently swallow the only way out of OTP, Create MPIN and + // Unlock. White, because it sits on the brand block. + if (onBack != null) + PositionedDirectional( + start: 4.w, + top: MediaQuery.paddingOf(context).top + 2.h, + child: _TapTarget( + onTap: onBack!, + semanticLabel: 'Back', + child: Icon( + Icons.arrow_back_rounded, + size: 22.sp, + color: ColorConstants.onAccent, + ), + ), + ), + ], + ), + Expanded( + child: Transform.translate( + offset: Offset(0, -_lift.h), + child: Container( + decoration: BoxDecoration( + color: ColorConstants.pureSurface, + borderRadius: BorderRadius.vertical( + top: Radius.circular(28.r), + ), + ), + child: SafeArea( + top: false, + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.fromLTRB(24.w, 0, 24.w, 8.h), + child: AuthEntrance( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 26.h), + if (step != null) ...[ + AuthSteps(step: step!, total: totalSteps), + SizedBox(height: 24.h), + ], + Text(title, style: AuthType.title), + if (subtitle != null) ...[ + SizedBox(height: 9.h), + Text(subtitle!, style: AuthType.subtitle), + ], + SizedBox(height: 28.h), + ...children, + ], + ), + ), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB( + 24.w, + 4.h, + 24.w, + 10.h + _lift.h, + ), + child: footer, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + /// How far the sheet is pulled up over the banner. + static const double _lift = 22; +} + +/// ───────────────────────────────────────────────────────────────────────── +/// THE BRAND BLOCK +/// +/// The masthead every screen in the onboarding flow wears. One block across +/// Sign In, OTP, Create MPIN and Unlock, so the four screens read as one +/// doorway rather than as four forms that happen to follow each other. +/// +/// ── The wordmark is knocked out, not re-exported ── +/// +/// `doormile-logo.png` is RGBA, so `BlendMode.srcIn` fills every +/// non-transparent pixel white and gives a clean reverse of the red original. +/// That is deliberately preferred to shipping a second white asset: two logo +/// files drift, and this one cannot. +/// +/// ── Full block, or strip ── +/// +/// The rule is what the screen does on arrival, and there are only two cases: +/// +/// • **Opens with the keyboard down** — Sign In, the one screen with nothing +/// focused. It gets the full 208dp block, and gives half of it back the +/// moment the rider taps the field. The brand moment is worth the height +/// while he is deciding to start, and worth nothing while he is typing. +/// +/// • **Opens with the keyboard already up** — OTP, Create MPIN, Unlock, all of +/// which focus their first box in a post-frame callback. These pass +/// [compact] and get the strip immediately, with no animation at all. +/// Adaptive sizing here would render the full block for one frame and then +/// shrink it, so every launch would open by animating the masthead away — +/// which is the splash screen this app deleted, wearing a different hat. +/// +/// Unlock is the screen that makes the second case matter: the rider opens it +/// every morning, and a daily animation is a daily tax. +/// ───────────────────────────────────────────────────────────────────────── +class AuthBrandBanner extends StatelessWidget { + /// Forces the strip. See the class note — this is for screens that raise the + /// keyboard themselves on the first frame. + final bool compact; + + const AuthBrandBanner({super.key, this.compact = false}); + + static const double _restHeight = 208; + static const double _typingHeight = 128; + + /// Source wordmark is 748×100, and the ratio is held explicitly so the mark + /// cannot be squashed by a layout change. + static const double _markAspect = 748 / 100; + + @override + Widget build(BuildContext context) { + final typing = compact || MediaQuery.viewInsetsOf(context).bottom > 0; + final markWidth = (typing ? 150.0 : 196.0).w; + + return AnimatedContainer( + duration: DesignConstants.motionState, + curve: Curves.easeOutCubic, + width: double.infinity, + height: (typing ? _typingHeight : _restHeight).h + + MediaQuery.paddingOf(context).top, + color: ColorConstants.primary, + child: ClipRect( + child: Stack( + children: [ + // ── The rings are the logo's own motif ── + // + // "mile" is drawn as concentric arcs in the Doormile mark, so the + // block is decorated with the same shape rather than with imported + // 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)), + + Center( + child: Padding( + padding: EdgeInsets.only( + top: MediaQuery.paddingOf(context).top * 0.5, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + label: 'Doormile', + image: true, + child: SizedBox( + width: markWidth, + height: markWidth / _markAspect, + child: ColorFiltered( + colorFilter: const ColorFilter.mode( + Colors.white, + BlendMode.srcIn, + ), + child: Image.asset( + 'assets/images/doormile-logo.png', + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + ), + ), + ), + ), + SizedBox(height: 12.h), + // Names the product under the company that makes it. The + // rider was handed "the Miler app" by his hub; the mark + // above says Doormile. This is the line that ties the two + // together, and it is the reason it is words and not a + // decorative flourish. + Text( + 'MILER · RIDER APP', + style: TextStyle( + fontSize: 10.5.sp, + fontWeight: FontWeight.w800, + letterSpacing: 1.6, + color: ColorConstants.onAccent.withValues(alpha: 0.72), + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +/// One faint concentric ring from the wordmark's arc motif. +class _BrandRing extends StatelessWidget { + final double size; + const _BrandRing({required this.size}); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size.w, + height: size.w, + child: CustomPaint(painter: _RingPainter()), + ); + } +} + +class _RingPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final centre = size.center(Offset.zero); + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = size.width * 0.055 + ..color = Colors.white.withValues(alpha: 0.07); + + // Three rings, stepping inward by the stroke plus its own gap — the same + // rhythm the "m" and "i" of the wordmark are built from. + for (var i = 0; i < 3; i++) { + canvas.drawCircle(centre, size.width * (0.46 - i * 0.13), paint); + } + } + + @override + bool shouldRepaint(covariant _RingPainter oldDelegate) => false; +} + +/// 44dp-minimum tap target around a small glyph. +class _TapTarget extends StatelessWidget { + final VoidCallback onTap; + final Widget child; + final String semanticLabel; + + const _TapTarget({ + required this.onTap, + required this.child, + required this.semanticLabel, + }); + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: semanticLabel, + child: InkResponse( + onTap: onTap, + radius: 24.r, + child: SizedBox( + width: ButtonSizes.minTapTarget, + height: ButtonSizes.minTapTarget, + child: Center(child: child), + ), + ), + ); + } +} + +/// The single action a screen exists for. +/// +/// Was copy-pasted three times, identically, with a `Row` of label + arrow +/// inside an `ElevatedButton.styleFrom` that overrode colour and shape only. +/// That last detail matters: the app theme styles `ElevatedButton` for +/// full-width form buttons with `vertical: 18` padding and a 56dp +/// `minimumSize`, and `styleFrom` keeps whatever you do not replace — so the +/// button only ever fitted because 56 happened to be the exact height these +/// screens asked for. The overrides below make the height this widget's own +/// business, so it cannot be broken by changing a number. +class AuthPrimaryButton extends StatelessWidget { + final String label; + final IconData? icon; + final bool loading; + final VoidCallback? onPressed; + + const AuthPrimaryButton({ + super.key, + required this.label, + this.icon = Icons.arrow_forward_rounded, + this.loading = false, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final enabled = onPressed != null && !loading; + + return SizedBox( + width: double.infinity, + height: ButtonSizes.primary, + child: ElevatedButton( + onPressed: enabled ? onPressed : null, + style: ElevatedButton.styleFrom( + backgroundColor: ColorConstants.primary, + foregroundColor: Colors.white, + // A disabled CTA reads as "not yet", so it stays recognisably the + // same button rather than turning grey and looking broken. + disabledBackgroundColor: ColorConstants.primary.withValues( + alpha: 0.30, + ), + disabledForegroundColor: Colors.white.withValues(alpha: 0.85), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ButtonSizes.radius.r), + ), + padding: EdgeInsets.symmetric(horizontal: 16.w), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: loading + ? SizedBox( + height: 22.sp, + width: 22.sp, + child: const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2.5, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: 16.sp, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + if (icon != null) ...[ + SizedBox(width: 8.w), + Icon(icon, color: Colors.white, size: 19.sp), + ], + ], + ), + ), + ); + } +} + +/// Quiet text action — Resend, Forgot MPIN, Change number. +class AuthTextAction extends StatelessWidget { + final String label; + final IconData? icon; + final VoidCallback? onPressed; + + const AuthTextAction({ + super.key, + required this.label, + this.icon, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final on = onPressed != null; + final color = on ? ColorConstants.primary : ColorConstants.secondaryText; + + return TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + minimumSize: Size(0, ButtonSizes.minTapTarget), + padding: EdgeInsets.symmetric(horizontal: 12.w), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(DesignConstants.radiusLg), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 15.sp, color: color), + SizedBox(width: 6.w), + ], + Text( + label, + style: TextStyle( + fontSize: 13.5.sp, + fontWeight: FontWeight.w800, + color: color, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ); + } +} + +/// ── Inline error ── +/// +/// The flow used to report a wrong or expired code with a floating `SnackBar` +/// at the bottom of the screen — under the keyboard, on the one screen +/// guaranteed to have the keyboard up. The rider saw the boxes clear themselves +/// and nothing else, which reads as a crash rather than a rejection. +/// +/// An error belongs next to the thing that caused it, and the input it refers to +/// turns red at the same time (see [AuthCodeRow.hasError]). +class AuthInlineError extends StatelessWidget { + final String? message; + const AuthInlineError({super.key, this.message}); + + @override + Widget build(BuildContext context) { + return AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + alignment: Alignment.topLeft, + child: message == null + ? const SizedBox(width: double.infinity) + : Padding( + padding: EdgeInsets.only(top: 10.h), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.error_outline_rounded, + size: 15.sp, + color: ColorConstants.errorRed, + ), + SizedBox(width: 6.w), + Expanded( + child: Text( + message!, + style: TextStyle( + color: ColorConstants.errorRed, + fontSize: 12.5.sp, + fontWeight: FontWeight.w700, + height: 1.35, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + ], + ), + ), + ); + } +} + +/// ── One row of single-character boxes ── +/// +/// Shared by the 6-digit OTP screen and both 4-digit MPIN screens, which +/// previously drew this control three times in three different ways: 46×56 at +/// radius 12 with a pink border; 70×70 at radius 16 filled grey with a +/// transparent border; 70×70 at radius 14 white with a pink border. Same +/// control, same flow, three looks. +/// +/// The boxes **flex** rather than carrying fixed widths. The old versions hard- +/// coded `46.w` / `70.w` inside a `spaceBetween` row, which only fits because +/// the design width happens to divide that way — four 70s plus gaps is 316 of a +/// 342 usable width, so a slightly wider gutter or a fifth box overflows. Sizing +/// from the space available cannot overflow at any count or screen width. +class AuthCodeRow extends StatelessWidget { + final List controllers; + final List nodes; + final bool obscure; + final bool hasError; + final void Function(String value, int index) onChanged; + + const AuthCodeRow({ + super.key, + required this.controllers, + required this.nodes, + required this.onChanged, + this.obscure = false, + this.hasError = false, + }); + + @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; + + 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), + ), + ), + ), + ], + ], + ); + } +} + +class _Box extends StatefulWidget { + final TextEditingController controller; + final FocusNode node; + final bool obscure; + final bool hasError; + final ValueChanged onChanged; + + const _Box({ + required this.controller, + required this.node, + required this.obscure, + required this.hasError, + required this.onChanged, + }); + + @override + State<_Box> createState() => _BoxState(); +} + +class _BoxState extends State<_Box> { + @override + void initState() { + super.initState(); + // Repaints only this box on focus, rather than the whole screen. The old + // screens called setState on every focus change and every keystroke to + // restyle six boxes, rebuilding the entire page each time. + widget.node.addListener(_onFocus); + } + + @override + void dispose() { + widget.node.removeListener(_onFocus); + super.dispose(); + } + + void _onFocus() { + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final focused = widget.node.hasFocus; + final filled = widget.controller.text.isNotEmpty; + + // The shared spec — see [MilerCodeBox]. The login code and the delivery OTP + // are two input models (per-box controllers here, one hidden field there) + // but the rider must meet one control. + return AnimatedContainer( + duration: DesignConstants.motionState, + decoration: MilerCodeBox.decoration( + focused: focused, + filled: filled, + hasError: widget.hasError, + accent: ColorConstants.primary, + ), + child: Center( + child: TextField( + controller: widget.controller, + focusNode: widget.node, + obscureText: widget.obscure, + obscuringCharacter: '•', + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(1), + ], + style: MilerCodeBox.digit( + ColorConstants.primary, + hasError: widget.hasError, + ), + cursorColor: ColorConstants.primary, + cursorWidth: 2, + decoration: const InputDecoration( + counterText: '', + filled: false, + isDense: true, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + ), + onChanged: widget.onChanged, + ), + ), + ); + } +} diff --git a/lib/views/onboardscreens/otp_page.dart b/lib/views/onboardscreens/otp_page.dart new file mode 100644 index 0000000..18ccce0 --- /dev/null +++ b/lib/views/onboardscreens/otp_page.dart @@ -0,0 +1,260 @@ +import 'dart:async'; +import 'package:flutter/material.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/helpers/constants/Font_constant.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/controllers/auth.dart'; +import 'package:miler/views/onboardscreens/Creat_mpin.dart'; +import 'package:miler/views/onboardscreens/auth_scaffold.dart'; +import 'package:sms_autofill/sms_autofill.dart'; + +/// ───────────────────────────────────────────────────────────────────────── +/// VERIFY — step 2 of 3. +/// +/// ── What changed and why ── +/// +/// **The number is now on screen.** The old copy said "Enter the 6-digit code +/// sent to your device", which is the one screen in the flow where the rider +/// most needs to check a fact — *which* number did it go to — and it did not +/// tell him. If he fat-fingered a digit on the previous screen, he waits out a +/// 60-second timer for a code that was never coming, with no way to tell. It now +/// echoes the number with a "Change" action beside it, which is what every app +/// that has thought about this does. +/// +/// **Failure is visible.** A wrong or expired code silently cleared all six +/// boxes and dropped a floating `SnackBar` at the bottom of the screen — +/// underneath the keyboard, which is up by definition here. The rider saw six +/// boxes empty themselves for no stated reason. The boxes now turn red and the +/// reason is printed under them. +/// +/// **The decorative tile is gone**, along with the 64px tinted SMS glyph inside +/// it: it pushed the code boxes a third of the way down the screen to illustrate +/// a word already in the heading. +/// ───────────────────────────────────────────────────────────────────────── +class OtpPage extends GetResponsiveView { + OtpPage({super.key}); + + @override + Widget builder() => const _OtpPageLayout(); +} + +class _OtpPageLayout extends StatefulWidget { + const _OtpPageLayout(); + + @override + State<_OtpPageLayout> createState() => _OtpPageLayoutState(); +} + +class _OtpPageLayoutState extends State<_OtpPageLayout> with CodeAutoFill { + final AuthController _auth = Get.put(AuthController()); + final List _otpControllers = List.generate( + 6, + (_) => TextEditingController(), + ); + final List _focusNodes = List.generate(6, (_) => FocusNode()); + + bool isVerifying = false; + String? _error; + int _secondsRemaining = 60; + Timer? _timer; + + @override + void initState() { + super.initState(); + _startTimer(); + listenForCode(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => _focusNodes[0].requestFocus(), + ); + } + + void _startTimer() { + _secondsRemaining = 60; + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (!mounted) { + timer.cancel(); + return; + } + if (_secondsRemaining > 0) { + setState(() => _secondsRemaining--); + } else { + timer.cancel(); + } + }); + } + + bool get _isComplete => _otpControllers.every((c) => c.text.isNotEmpty); + + String get _code => _otpControllers.map((c) => c.text).join(); + + Future _autoVerify() async { + if (isVerifying || !_isComplete) return; + setState(() { + isVerifying = true; + _error = null; + }); + + final ok = await _auth.verifyOtp(_code); + + if (!mounted) return; + setState(() => isVerifying = false); + + if (ok) { + openScreen(context, CreateMpin()); + } else { + // Clear and refocus as before, but say so — see the class note. + for (final c in _otpControllers) { + c.clear(); + } + _focusNodes[0].requestFocus(); + setState(() { + _error = _secondsRemaining == 0 + ? 'That code is incorrect or has expired. Request a new one.' + : 'That code is incorrect. Check the SMS and try again.'; + }); + } + } + + @override + void codeUpdated() { + final received = code ?? ''; + if (received.length >= 6) { + for (int i = 0; i < 6; i++) { + _otpControllers[i].text = received[i]; + } + setState(() => _error = null); + _autoVerify(); + } + } + + void _onDigitChanged(String val, int index) { + if (val.isNotEmpty && index < 5) { + _focusNodes[index + 1].requestFocus(); + } + if (val.isEmpty && index > 0) { + _focusNodes[index - 1].requestFocus(); + } + // Typing again clears a previous rejection — the red boxes refer to the code + // he just replaced, not the one he is entering now. + setState(() { + if (_error != null) _error = null; + }); + if (_isComplete) _autoVerify(); + } + + /// `+91 98765 43210`, grouped so a rider can actually proof-read it. + String get _maskedPhone { + final raw = (_auth.currentPhone ?? '').replaceAll(RegExp(r'\D'), ''); + if (raw.length != 10) return 'your mobile number'; + return '+91 ${raw.substring(0, 5)} ${raw.substring(5)}'; + } + + @override + void dispose() { + for (var c in _otpControllers) { + c.dispose(); + } + for (var f in _focusNodes) { + f.dispose(); + } + _timer?.cancel(); + cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final canResend = _secondsRemaining == 0; + + return AuthScaffold( + onBack: () => Get.back(), + // 1 of 2, not 2 of 3: the number screen no longer counts itself, and a + // counter whose first sighting is "step 2" is a counter the rider has + // already lost track of. See the note at the top of `Sign_in`. + step: 1, + totalSteps: 2, + // Compact, for the same reason as the two screens either side of it: the + // first box takes focus on the first frame. Included so the flow does not + // gain and lose its masthead between the number screen and the PIN one. + banner: const AuthBrandBanner(compact: true), + title: 'Enter the 6-digit code', + footer: AuthPrimaryButton( + label: 'Verify & continue', + loading: isVerifying, + onPressed: isVerifying || !_isComplete ? null : _autoVerify, + ), + children: [ + // ── Which number, and a way out ── + // The single most useful fact on this screen, and the old version left + // it off entirely. + Padding( + padding: EdgeInsets.only(bottom: 26.h), + child: Row( + children: [ + Expanded( + child: Text.rich( + TextSpan( + style: AuthType.subtitle, + children: [ + const TextSpan(text: 'Sent by SMS to '), + TextSpan( + text: _maskedPhone, + style: TextStyle( + fontWeight: FontWeight.w800, + color: ColorConstants.slateText, + fontFamily: FontConstants.fontFamily, + ), + ), + ], + ), + ), + ), + SizedBox(width: 8.w), + AuthTextAction( + label: 'Change', + onPressed: isVerifying ? null : () => Get.back(), + ), + ], + ), + ), + + AuthCodeRow( + controllers: _otpControllers, + nodes: _focusNodes, + hasError: _error != null, + onChanged: _onDigitChanged, + ), + AuthInlineError(message: _error), + + SizedBox(height: 22.h), + + // ── Resend ── + // + // The countdown used to be a disabled `TextButton` reading + // "Resend in 00:42", so the affordance and the timer were the same + // control and it looked broken for its first sixty seconds. The wait is + // now a plain sentence, and the button only exists once it can be used. + Center( + child: canResend + ? AuthTextAction( + label: 'Send a new code', + icon: Icons.refresh_rounded, + onPressed: () async { + setState(() => _error = null); + _startTimer(); + await _auth.sendOtp(); + }, + ) + : Text( + 'You can request a new code in ' + '0:${_secondsRemaining.toString().padLeft(2, '0')}', + style: AuthType.meta, + ), + ), + ], + ); + } +} diff --git a/lib/views/updatescreen/UpdateScreen.dart b/lib/views/updatescreen/UpdateScreen.dart new file mode 100644 index 0000000..bd90e11 --- /dev/null +++ b/lib/views/updatescreen/UpdateScreen.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:miler/views/helpers/constants/Font_constant.dart'; +import 'dart:io'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +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'; + +class UpdateScreen extends StatefulWidget { + final bool mIsForceUpdate; + final String mCurrentVersion; + final String mUpdateVersion; + + const UpdateScreen({ + super.key, + this.mIsForceUpdate = true, + required this.mCurrentVersion, + required this.mUpdateVersion, + }); + + @override + State createState() => _UpdateScreenState(); +} + +class _UpdateScreenState extends State + with WidgetsBindingObserver { + bool _isCheckingVersion = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && !_isCheckingVersion) { + checkVersionAndNavigate(); + } + } + + Future checkVersionAndNavigate() async { + if (_isCheckingVersion) return; + _isCheckingVersion = true; + + try { + await Future.delayed(const Duration(seconds: 1)); + + final newVersion = NewVersionPlus( + iOSId: '284882215', + androidId: "com.doormile.partner", + ); + + final status = await newVersion.getVersionStatus(); + + if (status != null) { + if (!status.canUpdate) { + if (mounted) { + _navigateToNextScreen(); + } + } + } + } catch (e) { + debugPrint("Error checking version: $e"); + } finally { + _isCheckingVersion = false; + } + } + + Future _navigateToNextScreen() async { + final prefs = await SharedPreferences.getInstance(); + final isLoggedOut = prefs.getBool('logged_out') == true; + final savedUserId = prefs.getInt('userid'); + + if (!mounted) return; + + if (!isLoggedOut && savedUserId != null && savedUserId > 0) { + // No duty gate — see the note in `app_bootstrap.dart`. + Get.offAll(() => const BottomPage()); + } else { + Get.offAll(() => Introscreen()); + } + } + + @override + Widget build(BuildContext context) { + double h = Get.height; + double w = Get.width; + + return WillPopScope( + onWillPop: () async { + if (widget.mIsForceUpdate) { + SystemNavigator.pop(); + return false; + } + return false; + }, + child: Scaffold( + backgroundColor: const Color(0xFFF8F9FB), + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + surfaceTintColor: Colors.transparent, + systemOverlayStyle: const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + ), + ), + + body: SafeArea( + top: true, + bottom: false, // Bottom safe area handled in bottomNavigationBar + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: Column( + children: [ + /// top spacing (responsive) + SizedBox(height: h * 0.06), + + /// 🚀 Image (auto scales) + Padding( + padding: EdgeInsets.symmetric(horizontal: w * 0.08), + child: Image.asset( + "assets/images/update.png", + height: h * 0.28, + fit: BoxFit.contain, + ), + ), + + SizedBox(height: h * 0.03), + + /// 🔥 Title + Text( + "A New Update is Available!", + style: TextStyle( + fontSize: h * 0.025, + fontWeight: FontWeight.bold, + fontFamily: FontConstants.fontFamily, + color: ColorConstants.onSurface, + ), + textAlign: TextAlign.center, + ), + + SizedBox(height: h * 0.015), + + /// 📄 Subtitle + Padding( + padding: EdgeInsets.symmetric(horizontal: w * 0.08), + child: Text( + "New features are here to make your app experience even smoother and more user-friendly!", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: h * 0.018, + color: ColorConstants.secondaryText, + height: 1.4, + fontFamily: FontConstants.fontFamily, + ), + ), + ), + + SizedBox(height: h * 0.03), + + /// Version Text + Text( + "Available version: ${widget.mUpdateVersion}", + style: TextStyle( + fontSize: h * 0.017, + color: ColorConstants.secondaryText, + fontFamily: FontConstants.fontFamily, + ), + ), + + Spacer(), // pushes content for large screens + ], + ), + ), + ), + ); + }, + ), + ), + + /// ⭐ Bottom Button (fixed, responsive) with bottom safe area + bottomNavigationBar: SafeArea( + top: false, + bottom: true, + child: Padding( + padding: EdgeInsets.fromLTRB(w * 0.06, 0, w * 0.06, h * 0.03), + // Height and label size used to be `h * 0.065` and `h * 0.022`, + // so this button was a different size on every device — ~42dp on a + // small phone, ~52 on a large one, and far taller on a tablet. It + // is the same button doing the same job either way, so it takes the + // same fixed size as every other primary action. + child: MilerButton( + label: "Update now", + onPressed: () => downloadActions(), + ), + ), + ), + ), + ); + } + + void downloadActions() async { + String url; + var s = Platform.isAndroid ? "Android" : "Ios"; + + if (s == "Android") { + url = + 'https://play.google.com/store/apps/details?id=com.doormile.partner'; + } else { + url = 'https://apps.apple.com/us/app/doormile/id1596895375ls=1'; + } + + final uri = Uri.parse(url); + + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + throw 'Could not launch App'; + } + } +} diff --git a/lib/widget/Bottom_page.dart b/lib/widget/Bottom_page.dart new file mode 100644 index 0000000..0344cb4 --- /dev/null +++ b/lib/widget/Bottom_page.dart @@ -0,0 +1,565 @@ +import 'dart:ui' show ImageFilter, lerpDouble; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import 'package:miler/views/Dashboard/pickups/pickups.dart'; +import 'package:miler/views/Dashboard/home/homepage.dart'; +import 'package:miler/views/Dashboard/activity/activity_page.dart'; +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'; + +class BottomPage extends StatefulWidget { + final int initialIndex; + final Widget? overridePage; + const BottomPage({super.key, this.initialIndex = 0, this.overridePage}); + + /// Cross-tab navigation request. + static final ValueNotifier tabRequest = ValueNotifier(null); + + /// Switch the shell to [index]. 0 Home · 1 Bookings · 2 Activity · 3 Account. + static void goToTab(int index) => tabRequest.value = index; + + /// The tab on screen right now. + /// + /// Published because the shell keeps every tab **alive**: the pages live in + /// an `IndexedStack`, which builds all four at launch and only changes which + /// one is painted. A page's `initState` therefore runs once, before the rider + /// has done anything — so a tab that loads its data there shows whatever was + /// true at app start for the rest of the session. Activity hit exactly that: + /// the rider finished a stop, opened the tab, and read a result fetched + /// before he had finished anything. + /// + /// A page that needs to be current listens to this and re-reads when its own + /// index arrives. Cheaper than rebuilding the tab from scratch, and it keeps + /// scroll position and state, which is why the IndexedStack is there. + static final ValueNotifier currentTab = ValueNotifier(0); + + /// Height of the floating bar at rest, and the gap to the screen edge. + static const double navBarHeight = 66; + static const double navBarGap = 10; + + /// Height of the bar once the rider has scrolled into a list. + /// + /// ── Why it shrinks instead of leaving ── + /// + /// It used to slide off the bottom of the screen entirely and come back when + /// he reversed. That buys 66pt of list, and costs him the ability to change + /// tab at all until he scrolls the other way — on a long Activity list, the + /// only route out is a gesture he has to know about. A control that vanishes + /// is not a smaller control; it is a missing one. + /// + /// Compacting keeps every target on screen and still hands back most of the + /// room: labels go, the icons draw in, and the pill narrows. This is what + /// Instagram's iOS tab bar does, and it is why that bar never feels in the + /// way despite never going away. + static const double navBarHeightCompact = 46; + + /// Room the bar claims at the foot of a tab page, system inset included. + /// + /// Always the *resting* height, even while the bar is compact. The bar is a + /// `Positioned` pill inside the shell's `Stack`, not a `bottomNavigationBar`, + /// so the Scaffold reserves no room for it and `SafeArea` knows nothing about + /// it — anything a page pins to the bottom ends up underneath it otherwise. + /// + /// Deliberately not the live height. Pages used to follow the bar up and down + /// through a `navVisible` notifier, which meant a pinned banner re-laid itself + /// out every time the rider changed scroll direction. Reserving the larger of + /// the two costs 20pt of gap while compact and buys a layout that never moves. + static double bottomInset(BuildContext context) => + MediaQuery.of(context).padding.bottom + navBarGap.h + navBarHeight.h; + + @override + State createState() => _BottomPageState(); +} + +class _BottomPageState extends State with TickerProviderStateMixin { + late int selected; + late final List _pages; + + late final AnimationController _tabAnim; + late final Animation _tabCurve; + + /// Drives the selection pill from the tab it was on to the tab just tapped. + /// + /// The pill used to be part of each tab — an `AnimatedContainer` whose fill + /// went from transparent to red — so going Account → Home faded one pill out + /// on the right while a second faded in on the left. Nothing moved, which is + /// why it appeared "suddenly": there was never anything to cross Bookings and + /// Activity with. + /// + /// There is now exactly one pill, owned by the bar, and this is its position + /// in tab units (2.0 is dead centre of Activity, 2.5 is between Activity and + /// Account). It is the single source of truth for the tint on every icon and + /// label too, so a tab lights up as the pill passes over it rather than + /// switching colour the instant it is tapped and waiting for the pill to + /// catch up. + /// Initialised *here* rather than in `initState`, deliberately. + /// + /// A `late final` assigned in `initState` is never filled in on a `State` that + /// already exists, and hot reload reuses the existing `State` — it does not + /// re-run `initState`. So the reloaded `build` read a controller this instance + /// had never been given and threw `LateInitializationError: Field '_pillAnim' + /// has not been initialized`, taking the whole app down to a red screen until + /// a full restart. + /// + /// A `late` field initialiser runs on first *access* instead, which happens + /// during that first post-reload build, so the field is simply there. `this` + /// is available because the initialiser is lazy — a plain (non-late) field + /// initialiser could not reach `vsync: this`. + late final AnimationController _pillAnim = AnimationController( + vsync: this, + // Slower than the 280ms the fading pills used. That duration was chosen for + // a cross-fade in place; a pill that has to physically cross two tabs needs + // long enough for the eye to follow it, or the travel is technically there + // and reads as a jump anyway. + duration: const Duration(milliseconds: 380), + value: 1.0, + ); + + /// Symmetric easing: it accelerates away from the old tab and settles into the + /// new one. `easeOutCubic` — right for something appearing — starts at full + /// speed, which on a sliding object looks like it was already moving before + /// the tap. + late final Animation _pillCurve = CurvedAnimation( + parent: _pillAnim, + curve: Curves.easeInOutCubic, + ); + + late Tween _pillTween = Tween( + begin: selected.toDouble(), + end: selected.toDouble(), + ); + + double get _pillPos => _pillTween.evaluate(_pillCurve); + + /// 0 at rest, 1 fully compact. Everything the bar changes as it shrinks — + /// height, corner radius, side inset, icon size, label opacity — is read off + /// this one value, so the parts cannot drift out of step with each other. + late final AnimationController _compactAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 260), + ); + + late final Animation _compact = CurvedAnimation( + parent: _compactAnim, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeOutCubic, + ); + + bool _isCompact = false; + + void _setCompact(bool compact) { + if (_isCompact == compact) return; + _isCompact = compact; + compact ? _compactAnim.forward() : _compactAnim.reverse(); + } + + double _scrollAccum = 0; + + static const double _compactAfter = 24; + static const double _expandAfter = 6; + + static const _navItems = [ + _NavItem(Icons.home_rounded, Icons.home_outlined, 'Home'), + _NavItem( + Icons.local_shipping_rounded, + Icons.local_shipping_outlined, + 'Bookings', + ), + // 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'), + ]; + + @override + void initState() { + super.initState(); + selected = widget.initialIndex; + BottomPage.currentTab.value = widget.initialIndex; + _pages = [ + widget.overridePage ?? const Homepage(), + const MyPickups(), + const ActivityPage(), + const ProfilePage(), + ]; + _tabAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + value: 1.0, + ); + _tabCurve = CurvedAnimation(parent: _tabAnim, curve: Curves.easeOutCubic); + + BottomPage.tabRequest.addListener(_onTabRequested); + } + + void _onTabRequested() { + final requested = BottomPage.tabRequest.value; + if (requested == null || !mounted) return; + BottomPage.tabRequest.value = null; + if (requested >= 0 && requested < _pages.length) _selectTab(requested); + } + + void _selectTab(int i) { + _scrollAccum = 0; + _setCompact(false); + if (i == selected) return; + + HapticFeedback.lightImpact(); + + // Start from where the pill actually *is*, not from the tab it was last + // headed to. Tapping again mid-slide otherwise teleports it back to the + // previous tab and re-runs the whole journey. + _pillTween = Tween(begin: _pillPos, end: i.toDouble()); + _pillAnim.forward(from: 0.0); + + setState(() => selected = i); + BottomPage.currentTab.value = i; + _tabAnim.forward(from: 0.0); + } + + @override + void dispose() { + BottomPage.tabRequest.removeListener(_onTabRequested); + _tabAnim.dispose(); + _pillAnim.dispose(); + _compactAnim.dispose(); + super.dispose(); + } + + bool _onScroll(ScrollNotification n) { + if (n.metrics.axis != Axis.vertical) return false; + + // A page too short to scroll can never compact the bar — otherwise a rider + // on a short screen ends up with the small version for no reason. + if (n.metrics.maxScrollExtent <= 0) { + _scrollAccum = 0; + _setCompact(false); + return false; + } + + if (n is! ScrollUpdateNotification) return false; + final delta = n.scrollDelta ?? 0; + if (delta == 0) return false; + + _scrollAccum = (_scrollAccum * delta > 0) ? _scrollAccum + delta : delta; + + if (delta > 0 && !_isCompact && _scrollAccum > _compactAfter) { + _scrollAccum = 0; + _setCompact(true); + } else if (delta < 0 && _isCompact && _scrollAccum < -_expandAfter) { + _scrollAccum = 0; + _setCompact(false); + } + return false; + } + + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).copyWith( + textTheme: Theme.of( + context, + ).textTheme.apply(fontFamily: FontConstants.fontFamily), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + ), + child: Scaffold( + backgroundColor: ColorConstants.surface, + extendBody: true, + body: NotificationListener( + onNotification: _onScroll, + child: Stack( + children: [ + // Page content + Positioned.fill( + child: FadeTransition( + opacity: _tabCurve, + child: AnimatedBuilder( + animation: _tabCurve, + builder: (_, child) => Transform.translate( + offset: Offset(0, 8 * (1 - _tabCurve.value)), + child: child, + ), + child: IndexedStack(index: selected, children: _pages), + ), + ), + ), + + // ── Floating frosted bar, compacting rather than leaving ── + // + // Every dimension is lerped off one animation value, so the + // height, the radius, the inset and the contents shrink together + // instead of each running its own clock. + AnimatedBuilder( + animation: Listenable.merge([_compact, _pillAnim]), + builder: (context, _) { + final k = _compact.value; + final side = lerpDouble(16.w, 30.w, k)!; + final height = lerpDouble( + BottomPage.navBarHeight.h, + BottomPage.navBarHeightCompact.h, + k, + )!; + final radius = lerpDouble(32.r, 23.r, k)!; + final pos = _pillPos; + + return Positioned( + left: side, + right: side, + bottom: + MediaQuery.of(context).padding.bottom + + BottomPage.navBarGap.h, + child: ClipRRect( + borderRadius: BorderRadius.circular(radius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20.0, sigmaY: 20.0), + child: DecoratedBox( + decoration: BoxDecoration( + color: ColorConstants.pureSurface.withValues( + alpha: 0.82, + ), + borderRadius: BorderRadius.circular(radius), + border: Border.all( + color: Colors.black.withValues(alpha: 0.06), + width: 1.0, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.07), + blurRadius: 28, + offset: const Offset(0, 8), + ), + ], + ), + child: SizedBox( + height: height, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 6.w, + vertical: lerpDouble(6.h, 4.h, k)!, + ), + child: LayoutBuilder( + builder: (context, box) { + // Every tab gets an equal slot, matching the + // `Expanded` row below it, so the pill lands + // exactly under an icon at whole values of + // `pos`. + final slot = box.maxWidth / _navItems.length; + return Stack( + children: [ + PositionedDirectional( + start: slot * pos, + top: 0, + bottom: 0, + width: slot, + child: Container( + margin: EdgeInsets.symmetric( + horizontal: 3.w, + ), + decoration: BoxDecoration( + // Brand, to agree with the glyph + // it sits behind — see the tint in + // [_NavBarItem]. + color: ColorConstants.primaryWash, + borderRadius: BorderRadius.circular( + lerpDouble(34.r, 20.r, k)!, + ), + ), + ), + ), + Row( + children: List.generate( + _navItems.length, + (i) { + // How lit this tab is: 1 with the + // pill on it, 0 once the pill is a + // whole tab away. The tabs being + // crossed therefore warm up and cool + // down as it passes, which is what + // sells the movement. + final t = (1.0 - (pos - i).abs()) + .clamp(0.0, 1.0); + return Expanded( + child: _NavBarItem( + item: _navItems[i], + t: t, + compact: k, + onTap: () => _selectTab(i), + ), + ); + }, + ), + ), + ], + ); + }, + ), + ), + ), + ), + ), + ), + ); + }, + ), + ], + ), + ), + ), + ); + } +} + +class _NavItem { + final IconData activeIcon; + final IconData inactiveIcon; + final String label; + const _NavItem(this.activeIcon, this.inactiveIcon, this.label); +} + +class _NavBarItem extends StatelessWidget { + final _NavItem item; + + /// How lit this tab is, 0 → 1, driven by where the selection pill currently + /// is rather than by which tab is selected. + /// + /// This is the whole point of the redesign. With a boolean the icon flipped + /// red the instant it was tapped while the pill was still two tabs away — + /// destination and vehicle arriving at different times. Tying the tint to the + /// pill's distance instead means Bookings and Activity glow briefly as it + /// crosses them and fade again behind it. + final double t; + + /// 0 at rest, 1 fully compact. The label collapses and the icon draws in as + /// this rises — see [BottomPage.navBarHeightCompact]. + final double compact; + + final VoidCallback onTap; + + const _NavBarItem({ + required this.item, + required this.t, + required this.compact, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + // ── The selected tab wears the brand ── + // + // This was [ColorConstants.emphasis], the neutral slate, on the rule that + // colour marks meaning and neutral marks state. The brand mark is not on + // any tab now that Home carries the rider's own avatar, so the nav bar is + // where Doormile's red belongs: it is the one piece of chrome on screen at + // all times, and the selected tab is the one thing on it worth colouring. + // + // #960019 reads at 9.4:1 on the bar's near-white frost, so it is legible + // outdoors — which is the only reason the neutral was ever safer. + final Color activeColor = ColorConstants.primary; + final Color inactiveColor = ColorConstants.onSurfaceVariant; + + // Eased rather than linear: a straight ramp makes every tab the pill passes + // over look half-selected, so the bar reads as having no clear selection + // mid-slide. Cubed keeps the glow tight around the pill. + final lit = t * t * t; + final tint = Color.lerp(inactiveColor, activeColor, lit)!; + + // The app's primary navigation was four unlabelled `InkWell`s: a screen + // reader announced "Home", "Bookings", "Activity", "Account" as loose text + // with no role and no indication of which one the rider was on. `selected` + // is the part that matters — without it he cannot tell where he is. + return Semantics( + button: true, + selected: lit > 0.5, + label: item.label, + excludeSemantics: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(DesignConstants.radiusXl), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + // Transparent, not tinted: the pill behind the row is the fill now. + child: Padding( + padding: EdgeInsets.symmetric(vertical: 4.h, horizontal: 4.w), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Transform.scale( + scale: (1.0 + 0.08 * lit) * (1 - 0.06 * compact), + // Filled and outline glyph stacked and cross-faded on the same + // value, so the icon fills in as the pill arrives. An + // `AnimatedSwitcher` keyed off a bool ran on its own 220ms clock + // and finished well before the pill did. + child: Stack( + alignment: Alignment.center, + children: [ + Opacity( + opacity: 1 - lit, + child: Icon( + item.inactiveIcon, + color: tint, + size: 21.sp, + ), + ), + Opacity( + opacity: lit, + child: Icon(item.activeIcon, color: tint, size: 21.sp), + ), + ], + ), + ), + // ── The label collapses, it does not just fade ── + // + // Fading alone would leave its height behind, so the bar could + // not actually get shorter — it would shrink around a blank + // line. `Align(heightFactor:)` inside a `ClipRect` hands the + // room back as the text goes. + // + // The opacity runs ahead of the height (×1.8) so the label is + // gone before the line has finished closing, rather than being + // squashed on the way out. + ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: (1 - compact).clamp(0.0, 1.0), + child: Opacity( + opacity: (1 - compact * 1.8).clamp(0.0, 1.0), + child: Padding( + padding: EdgeInsets.only(top: 2.h), + child: Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + color: tint, + fontSize: 10.5.sp, + fontWeight: FontWeight.lerp( + FontWeight.w500, + FontWeight.w700, + lit, + ), + fontFamily: FontConstants.fontFamily, + letterSpacing: -0.1, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..c7ea17f --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..e9f9d2c --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "doormile") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.doormile") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..27860e8 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..d8a4062 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..04f81f4 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux + file_selector_linux + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..7ed6f3e --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..4340ffc --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..21c26b3 --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,144 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "Doormile"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "Doormile"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..8f20fb5 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..d4e0569 --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..df4c964 --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..e79501e --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..63b8115 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,42 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import audioplayers_darwin +import battery_plus +import connectivity_plus +import device_info_plus +import file_selector_macos +import firebase_core +import firebase_messaging +import flutter_local_notifications +import flutter_tts +import geolocator_apple +import package_info_plus +import path_provider_foundation +import shared_preferences_foundation +import url_launcher_macos +import wakelock_plus +import webview_flutter_wkwebview + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + BatteryPlusMacosPlugin.register(with: registry.registrar(forPlugin: "BatteryPlusMacosPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..380e446 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,727 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* doormile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "doormile.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* doormile.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* doormile.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/doormile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/doormile"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/doormile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/doormile"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/doormile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/doormile"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..fc6bf80 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..09c816f --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,131 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1", + "version" : "11.3.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", + "version" : "12.14.0" + } + }, + { + "identity" : "flutterfire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/flutterfire", + "state" : { + "revision" : "1fc2adfbeb6e132e49ba72d04c727227f9827b3e", + "version" : "4.2.1-firebase-core-swift" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", + "version" : "3.6.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", + "version" : "12.14.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", + "version" : "8.1.1" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", + "version" : "2.30910.1" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + } + ], + "version" : 2 +} diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..44ca6b1 --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..59c6d39 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..fc6bf80 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..09c816f --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,131 @@ +{ + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "bbe8b69694d7873315fd3a4ad41efe043e1c07c5", + "version" : "1.2024072200.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "bb4002485ff867768dec13bf904a2ddb050bd1b1", + "version" : "11.3.0" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "8d5b4189f1f482df8d5c58c9985ea70491ef5382", + "version" : "12.14.0" + } + }, + { + "identity" : "flutterfire", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/flutterfire", + "state" : { + "revision" : "1fc2adfbeb6e132e49ba72d04c727227f9827b3e", + "version" : "4.2.1-firebase-core-swift" + } + }, + { + "identity" : "google-ads-on-device-conversion-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk", + "state" : { + "revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64", + "version" : "3.6.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "219e564a8510e983e675c94f77f7f7c50049f22d", + "version" : "12.14.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822", + "version" : "8.1.1" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "75b31c842f664a0f46a2e590a570e370249fd8f6", + "version" : "1.69.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "c0ac7575d70050c2973ba2318bd5af47f8e8153a", + "version" : "5.3.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "3851d94a41890dea16dc3db34caf60e585cb4163", + "version" : "2.30910.1" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "f4a19a3c313dc2616c70bb49d29a799fb16be837", + "version" : "2.4.1" + } + } + ], + "version" : 2 +} diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..c5c474d --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..8d4e7cb --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..4632c69 --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..f53e1c5 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = Doormile + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..b398823 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..d93e5dc --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..fb4d7d3 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..51d0967 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..3733c1a --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..ab30cba --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..04336df --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..21fe1ab --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/nearlerider-keystore.jks b/nearlerider-keystore.jks new file mode 100644 index 0000000..7f4d142 Binary files /dev/null and b/nearlerider-keystore.jks differ diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..4260351 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1658 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "8a1f5f3020ef2a74fb93f7ab3ef127a8feea33a7a2276279113660784ee7516a" + url: "https://pub.dev" + source: hosted + version: "1.3.64" + alp_animated_splashscreen: + dependency: "direct main" + description: + name: alp_animated_splashscreen + sha256: b39c655e4de1248028d7cfaffdcd9dced6cb20d6e9465eb42d1a567f8197e5ce + url: "https://pub.dev" + source: hosted + version: "0.0.6" + animated_stack: + dependency: "direct main" + description: + name: animated_stack + sha256: "78a2ae12cde6db384a481f749e1fcd2de7c2cd94c557e2484dab4ce71e74d575" + url: "https://pub.dev" + source: hosted + version: "0.3.3" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4" + url: "https://pub.dev" + source: hosted + version: "6.5.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + url: "https://pub.dev" + source: hosted + version: "4.2.1" + battery_plus: + dependency: "direct main" + description: + name: battery_plus + sha256: ad16fcb55b7384be6b4bbc763d5e2031ac7ea62b2d9b6b661490c7b9741155bf + url: "https://pub.dev" + source: hosted + version: "7.0.0" + battery_plus_platform_interface: + dependency: transitive + description: + name: battery_plus_platform_interface + sha256: e8342c0f32de4b1dfd0223114b6785e48e579bfc398da9471c9179b907fa4910 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + bottom_inset_observer: + dependency: transitive + description: + name: bottom_inset_observer + sha256: cbfb01e0e07cc4922052701786d5e607765a6f54e1844f41061abf8744519a7d + url: "https://pub.dev" + source: hosted + version: "3.1.0" + bottom_sheet: + dependency: "direct main" + description: + name: bottom_sheet + sha256: efd28f52357d23e1c01eaeb45466b407f1e29318305bd6d10baf814fda18bd7e + url: "https://pub.dev" + source: hosted + version: "4.0.4" + buffer: + dependency: transitive + description: + name: buffer + sha256: "389da2ec2c16283c8787e0adaede82b1842102f8c8aae2f49003a766c5c6b3d1" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + circular_countdown_timer: + dependency: "direct main" + description: + name: circular_countdown_timer + sha256: "608d166c8c659af5740ffec8859e1429a4849b706675a529dc381ef40784697b" + url: "https://pub.dev" + source: hosted + version: "0.2.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + confetti: + dependency: "direct main" + description: + name: confetti + sha256: "79376a99648efbc3f23582f5784ced0fe239922bd1a0fb41f582051eba750751" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + contained_tab_bar_view: + dependency: "direct main" + description: + name: contained_tab_bar_view + sha256: "87e35f47992764e45ab6205493f2f90c1e3fac4ad84f2b2285b73414727b756e" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + container_tab_indicator: + dependency: transitive + description: + name: container_tab_indicator + sha256: b0bdd73bb495c31c5711cefa363511b10bb3ebcfc007b603a2599401ebe6b2d9 + url: "https://pub.dev" + source: hosted + version: "0.3.0" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + url: "https://pub.dev" + source: hosted + version: "0.3.5+1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" + url: "https://pub.dev" + source: hosted + version: "12.3.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + dots_indicator: + dependency: transitive + description: + name: dots_indicator + sha256: c070af5058a084ba7b354df4b4c26c719595d70a3531eea6edd8af8716684ba3 + url: "https://pub.dev" + source: hosted + version: "4.0.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" + event_bus: + dependency: transitive + description: + name: event_bus + sha256: "1a55e97923769c286d295240048fc180e7b0768902c3c2e869fe059aafa15304" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "1f2dfd9f535d81f8b06d7a50ecda6eac1e6922191ed42e09ca2c84bd2288927c" + url: "https://pub.dev" + source: hosted + version: "4.2.1" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: ff18fabb0ad0ed3595d2f2c85007ecc794aadecdff5b3bb1460b7ee47cded398 + url: "https://pub.dev" + source: hosted + version: "3.3.0" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "22086f857d2340f5d973776cfd542d3fb30cf98e1c643c3aa4a7520bb12745bb" + url: "https://pub.dev" + source: hosted + version: "16.0.4" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: a59920cbf2eb7c83d34a5f354331210ffec116b216dc72d864d8b8eb983ca398 + url: "https://pub.dev" + source: hosted + version: "4.7.4" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "1183e40e6fd2a279a628951cc3b639fcf5ffe7589902632db645011eb70ebefb" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + floating: + dependency: "direct main" + description: + name: floating + sha256: e51ce1dbcab3ea83da0ad1b07f2091b99c0e8680184c780a000567f41234d454 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_animate: + dependency: transitive + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" + flutter_foreground_task: + dependency: "direct main" + description: + name: flutter_foreground_task + sha256: bd01a61896c1190a8f203fb5cfc136c884ae9f28c037e7a6f9a441853180f9c0 + url: "https://pub.dev" + source: hosted + version: "7.5.2" + flutter_keyboard_visibility_linux: + dependency: transitive + description: + name: flutter_keyboard_visibility_linux + sha256: "6fba7cd9bb033b6ddd8c2beb4c99ad02d728f1e6e6d9b9446667398b2ac39f08" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_macos: + dependency: transitive + description: + name: flutter_keyboard_visibility_macos + sha256: c5c49b16fff453dfdafdc16f26bdd8fb8d55812a1d50b0ce25fc8d9f2e53d086 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_keyboard_visibility_platform_interface: + dependency: transitive + description: + name: flutter_keyboard_visibility_platform_interface + sha256: e43a89845873f7be10cb3884345ceb9aebf00a659f479d1c8f4293fcb37022a4 + url: "https://pub.dev" + source: hosted + version: "2.0.0" + flutter_keyboard_visibility_temp_fork: + dependency: transitive + description: + name: flutter_keyboard_visibility_temp_fork + sha256: e3d02900640fbc1129245540db16944a0898b8be81694f4bf04b6c985bed9048 + url: "https://pub.dev" + source: hosted + version: "0.1.5" + flutter_keyboard_visibility_windows: + dependency: transitive + description: + name: flutter_keyboard_visibility_windows + sha256: fc4b0f0b6be9b93ae527f3d527fb56ee2d918cd88bbca438c478af7bcfd0ef73 + url: "https://pub.dev" + source: hosted + version: "1.0.0" + flutter_launcher_icons: + dependency: "direct main" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications: + dependency: "direct main" + description: + name: flutter_local_notifications + sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" + url: "https://pub.dev" + source: hosted + version: "19.5.0" + flutter_local_notifications_linux: + dependency: transitive + description: + name: flutter_local_notifications_linux + sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_local_notifications_platform_interface: + dependency: transitive + description: + name: flutter_local_notifications_platform_interface + sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + url: "https://pub.dev" + source: hosted + version: "9.1.0" + flutter_local_notifications_windows: + dependency: transitive + description: + name: flutter_local_notifications_windows + sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: ce1debbb29cade61964334b2a19c7c76e5bb2ef7dacf126c6424ee44036232f8 + url: "https://pub.dev" + source: hosted + version: "8.3.1" + flutter_native_splash: + dependency: "direct main" + description: + name: flutter_native_splash + sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" + url: "https://pub.dev" + source: hosted + version: "2.4.7" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" + flutter_screenutil: + dependency: "direct main" + description: + name: flutter_screenutil + sha256: "8239210dd68bee6b0577aa4a090890342d04a136ce1c81f98ee513fc0ce891de" + url: "https://pub.dev" + source: hosted + version: "5.9.3" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" + flutter_slidable: + dependency: "direct main" + description: + name: flutter_slidable + sha256: ea369262929d3cc6ebf9d8a00c196127966f117fe433a5e5cb47fb08008ca203 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: bdf2fc4483e74450dc9fc6fe6a9b6a5663e108d4d0dad3324a22c8e26bf48af4 + url: "https://pub.dev" + source: hosted + version: "4.2.3" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + geoclue: + dependency: transitive + description: + name: geoclue + sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f + url: "https://pub.dev" + source: hosted + version: "0.1.1" + geocoding: + dependency: "direct main" + description: + name: geocoding + sha256: "606be036287842d779d7ec4e2f6c9435fc29bbbd3c6da6589710f981d8852895" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + geocoding_android: + dependency: transitive + description: + name: geocoding_android + sha256: ba810da90d6633cbb82bbab630e5b4a3b7d23503263c00ae7f1ef0316dcae5b9 + url: "https://pub.dev" + source: hosted + version: "4.0.1" + geocoding_ios: + dependency: transitive + description: + name: geocoding_ios + sha256: "18ab1c8369e2b0dcb3a8ccc907319334f35ee8cf4cfef4d9c8e23b13c65cb825" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + geocoding_platform_interface: + dependency: transitive + description: + name: geocoding_platform_interface + sha256: "8c2c8226e5c276594c2e18bfe88b19110ed770aeb7c1ab50ede570be8b92229b" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: "79939537046c9025be47ec645f35c8090ecadb6fe98eba146a0d25e8c1357516" + url: "https://pub.dev" + source: hosted + version: "14.0.2" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_linux: + dependency: transitive + description: + name: geolocator_linux + sha256: c4e966f0a7a87e70049eac7a2617f9e16fd4c585a26e4330bdfc3a71e6a721f3 + url: "https://pub.dev" + source: hosted + version: "0.2.3" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + get: + dependency: "direct main" + description: + name: get + sha256: "5ed34a7925b85336e15d472cc4cfe7d9ebf4ab8e8b9f688585bf6b50f4c3d79a" + url: "https://pub.dev" + source: hosted + version: "4.7.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + url: "https://pub.dev" + source: hosted + version: "6.3.3" + gsettings: + dependency: transitive + description: + name: gsettings + sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" + url: "https://pub.dev" + source: hosted + version: "0.2.8" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" + url: "https://pub.dev" + source: hosted + version: "0.8.13+10" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" + url: "https://pub.dev" + source: hosted + version: "0.8.13+3" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: "direct dev" + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + introduction_screen: + dependency: "direct main" + description: + name: introduction_screen + sha256: "47ad51281f86c3ed47e0c1a0008899ad253ca71e8b626fd862e55993825a271b" + url: "https://pub.dev" + source: hosted + version: "4.0.0" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "84b776b100a84a684a0aaa3fa4dc2c9b1931c658de533701ee4e09bfa991f8dc" + url: "https://pub.dev" + source: hosted + version: "0.10.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + url: "https://pub.dev" + source: hosted + version: "6.0.0" + lottie: + dependency: "direct main" + description: + name: lottie + sha256: "8ae0be46dbd9e19641791dc12ee480d34e1fd3f84c749adc05f3ad9342b71b95" + url: "https://pub.dev" + source: hosted + version: "3.3.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + url: "https://pub.dev" + source: hosted + version: "1.18.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + minio: + dependency: "direct main" + description: + name: minio + sha256: ee2ce47766e46c7d164f960f2f5ed6a9a82844d877f6b82574f6876ec50c56d1 + url: "https://pub.dev" + source: hosted + version: "3.5.8" + mqtt_client: + dependency: "direct main" + description: + name: mqtt_client + sha256: fd22ea00a4c7b5623e01000a91a256d62a8bacba38e9812170458070c52affed + url: "https://pub.dev" + source: hosted + version: "10.11.9" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + new_version_plus: + dependency: "direct main" + description: + name: new_version_plus + sha256: "13081e189d3334c45bd708ffb42a0f1043a316e8c77d458d1ccb486d5572e2c6" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + pin_input_text_field: + dependency: transitive + description: + name: pin_input_text_field + sha256: f45683032283d30b670ec343781660655e3e1953438b281a0bc6e2d358486236 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + qr: + dependency: transitive + description: + name: qr + sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + qr_flutter: + dependency: "direct main" + description: + name: qr_flutter + sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + raindrop_animated_splash_screen: + dependency: "direct main" + description: + name: raindrop_animated_splash_screen + sha256: "5756e139c2ef7e395c92795ae155da43c22d20e84dddbf13e8b6a88ff7e28709" + url: "https://pub.dev" + source: hosted + version: "0.0.2" + scratcher: + dependency: "direct main" + description: + name: scratcher + sha256: "540be49eb4773e7e300d4a85a9f93c600f7386cd2a82abb5b40a7f8f245e9785" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + url: "https://pub.dev" + source: hosted + version: "2.4.18" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shimmer: + dependency: "direct main" + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + slide_to_submit_button: + dependency: "direct main" + description: + name: slide_to_submit_button + sha256: d3d71ceb8545a6ae72d310684d8138d641075e54495b4613b2c14279fd1f52ac + url: "https://pub.dev" + source: hosted + version: "1.0.3" + slider_button_lite: + dependency: "direct main" + description: + name: slider_button_lite + sha256: "941b9b7e335d7387afe4def4ca3e832b77ef5ed7c11e4d3ccbafad103a0cbb83" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + smooth_page_indicator: + dependency: "direct main" + description: + name: smooth_page_indicator + sha256: b21ebb8bc39cf72d11c7cfd809162a48c3800668ced1c9da3aade13a32cf6c1c + url: "https://pub.dev" + source: hosted + version: "1.2.1" + sms_autofill: + dependency: "direct main" + description: + name: sms_autofill + sha256: c65836abe9c1f62ce411bb78d5546a09ece4297558070b1bd871db1db283aaf9 + url: "https://pub.dev" + source: hosted + version: "2.4.1" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + tab_indicator_styler: + dependency: "direct main" + description: + name: tab_indicator_styler + sha256: "9e7e90367e20f71f3882fc6578fdcced35ab1c66ab20fcb623cdcc20d2796c76" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + url: "https://pub.dev" + source: hosted + version: "0.7.11" + timezone: + dependency: transitive + description: + name: timezone + sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 + url: "https://pub.dev" + source: hosted + version: "0.10.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + url: "https://pub.dev" + source: hosted + version: "2.3.1" + upower: + dependency: transitive + description: + name: upower + sha256: cf042403154751180affa1d15614db7fa50234bc2373cd21c3db666c38543ebf + url: "https://pub.dev" + source: hosted + version: "0.7.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vibration: + dependency: "direct main" + description: + name: vibration + sha256: "1fd51cb0f91c6d512734ca0e282dd87fbc7f389b6da5f03c77709ba2cf8fa901" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + vibration_platform_interface: + dependency: transitive + description: + name: vibration_platform_interface + sha256: "4134fbfcd427b59a7a91f8733292e4e9b29a7f1e8224ff0d80f5745fbf0743c6" + url: "https://pub.dev" + source: hosted + version: "0.1.1" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: c3e4fe614b1c814950ad07186007eff2f2e5dd2935eba7b9a9a1af8e5885f1ba + url: "https://pub.dev" + source: hosted + version: "4.13.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: eeeb3fcd5f0ff9f8446c9f4bbc18a99b809e40297528a3395597d03aafb9f510 + url: "https://pub.dev" + source: hosted + version: "4.10.11" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0" + url: "https://pub.dev" + source: hosted + version: "2.14.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: e49f378ed066efb13fc36186bbe0bd2425630d4ea0dbc71a18fdd0e4d8ed8ebc + url: "https://pub.dev" + source: hosted + version: "3.23.5" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.0-0 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..da896df --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,126 @@ +name: miler +description: Miler Rider - Smarter Pickups, Powered by AI +publish_to: "none" + +version: 1.2.28+144 + + +environment: + sdk: ^3.9.2 + +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + qr_flutter: ^4.1.0 + introduction_screen: ^4.0.0 + smooth_page_indicator: ^1.2.1 + get: ^4.7.2 + http: ^1.2.2 + device_info_plus: ^12.2.0 + firebase_core: ^4.2.0 + firebase_messaging: ^16.0.3 + image_picker: ^1.1.2 + shared_preferences: ^2.3.3 + sms_autofill: ^2.3.0 + webview_flutter: ^4.8.0 + audioplayers: ^6.5.1 + flutter_local_notifications: ^19.5.0 + path_provider: ^2.1.4 + geolocator: ^14.0.2 + connectivity_plus: ^7.0.0 + slide_to_submit_button: ^1.0.3 + flutter_tts: ^4.0.2 + vibration: ^3.1.4 + contained_tab_bar_view: ^0.8.0 + tab_indicator_styler: ^2.0.0 + geocoding: ^4.0.0 + fl_chart: ^1.1.1 + battery_plus: ^7.0.0 + provider: ^6.1.5+1 + slider_button_lite: ^1.0.1 + url_launcher: ^6.3.2 + circular_countdown_timer: ^0.2.4 + lottie: ^3.3.2 + flutter_foreground_task: ^7.0.3 + floating: ^6.0.0 + bottom_sheet: ^4.0.4 + wakelock_plus: ^1.1.1 + minio: ^3.5.8 + package_info_plus: ^8.0.0 + flutter_native_splash: ^2.4.7 + new_version_plus: ^0.1.1 + raindrop_animated_splash_screen: ^0.0.2 + alp_animated_splashscreen: ^0.0.6 + flutter_slidable: ^4.0.3 + animated_stack: ^0.3.3 + flutter_screenutil: ^5.9.3 + google_fonts: ^6.3.3 + shimmer: ^3.0.0 + flutter_launcher_icons: ^0.14.4 + scratcher: ^2.5.0 + confetti: ^0.8.0 + permission_handler: ^12.0.1 + mqtt_client: ^10.3.0 + flutter_map: ^8.3.1 + latlong2: ^0.10.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 + # The camera seam. StopVerificationPage's review step is only reachable once + # a photo exists, so the tests swap ImagePickerPlatform.instance for a fake + # that returns a file on disk. Test-only — the app talks to image_picker. + image_picker_platform_interface: ^2.10.0 + + +flutter_launcher_icons: + android: true + ios: true + web: + generate: true + image_path: "assets/images/doormile_icon.png" + image_path: "assets/images/doormile_icon.png" + remove_alpha_ios: true +flutter_native_splash: + color: "#FFFFFF" + image: assets/images/doormile_icon.png + android_12: + image: assets/images/doormile_icon.png + icon_background_color: "#FFFFFF" + + + +flutter: + uses-material-design: true + + assets: + - assets/images/ + - assets/audio/ + - assets/lotties/ + + fonts: + - family: Manrope + fonts: + - asset: assets/fonts/Manrope/Manrope-VF.ttf + - family: PlusJakartaSans + fonts: + - asset: assets/fonts/PlusJakartaSans/PlusJakartaSans-VF.ttf + - family: poppins + fonts: + - asset: assets/fonts/poppins/poppins-Light.ttf + weight: 300 + - asset: assets/fonts/poppins/poppins-Regular.ttf + weight: 400 + - asset: assets/fonts/poppins/poppins-Medium.ttf + weight: 500 + - asset: assets/fonts/poppins/poppins-SemiBold.ttf + weight: 600 + - asset: assets/fonts/poppins/poppins-Bold.ttf + weight: 700 + - asset: assets/fonts/poppins/poppins-ExtraBold.ttf + weight: 800 + - asset: assets/fonts/poppins/poppins-Black.ttf + weight: 900 diff --git a/refer/confirm_collection/screen.png b/refer/confirm_collection/screen.png new file mode 100644 index 0000000..ca40611 --- /dev/null +++ b/refer/confirm_collection/screen.png @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/refer/earnings_summary/screen.png b/refer/earnings_summary/screen.png new file mode 100644 index 0000000..ca40611 --- /dev/null +++ b/refer/earnings_summary/screen.png @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/refer/pickup_detail/screen.png b/refer/pickup_detail/screen.png new file mode 100644 index 0000000..ca40611 --- /dev/null +++ b/refer/pickup_detail/screen.png @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/refer/rider_operations_system/DESIGN.md b/refer/rider_operations_system/DESIGN.md new file mode 100644 index 0000000..fe2e9eb --- /dev/null +++ b/refer/rider_operations_system/DESIGN.md @@ -0,0 +1,151 @@ +--- +name: Rider Operations System +colors: + surface: '#fff8f7' + surface-dim: '#f1d3d1' + surface-bright: '#fff8f7' + surface-container-lowest: '#ffffff' + surface-container-low: '#fff0ef' + surface-container: '#ffe9e7' + surface-container-high: '#ffe1df' + surface-container-highest: '#fadbd9' + on-surface: '#281716' + on-surface-variant: '#5c403e' + inverse-surface: '#3e2c2b' + inverse-on-surface: '#ffedeb' + outline: '#906f6d' + outline-variant: '#e5bdbb' + surface-tint: '#bd0e25' + primary: '#960019' + on-primary: '#ffffff' + primary-container: '#c01227' + on-primary-container: '#ffd1ce' + inverse-primary: '#ffb3af' + secondary: '#5f5e5e' + on-secondary: '#ffffff' + secondary-container: '#e2dfde' + on-secondary-container: '#636262' + tertiary: '#004e6f' + on-tertiary: '#ffffff' + tertiary-container: '#006791' + on-tertiary-container: '#b8e1ff' + error: '#ba1a1a' + on-error: '#ffffff' + error-container: '#ffdad6' + on-error-container: '#93000a' + primary-fixed: '#ffdad7' + primary-fixed-dim: '#ffb3af' + on-primary-fixed: '#410005' + on-primary-fixed-variant: '#930018' + secondary-fixed: '#e5e2e1' + secondary-fixed-dim: '#c8c6c5' + on-secondary-fixed: '#1c1b1b' + on-secondary-fixed-variant: '#474746' + tertiary-fixed: '#c7e7ff' + tertiary-fixed-dim: '#86cffe' + on-tertiary-fixed: '#001e2e' + on-tertiary-fixed-variant: '#004c6c' + background: '#fff8f7' + on-background: '#281716' + surface-variant: '#fadbd9' +typography: + display-lg: + fontFamily: Public Sans + fontSize: 32px + fontWeight: '700' + lineHeight: 40px + letterSpacing: -0.02em + headline-md: + fontFamily: Public Sans + fontSize: 24px + fontWeight: '700' + lineHeight: 32px + headline-sm: + fontFamily: Public Sans + fontSize: 20px + fontWeight: '600' + lineHeight: 28px + body-lg: + fontFamily: Public Sans + fontSize: 18px + fontWeight: '500' + lineHeight: 26px + body-md: + fontFamily: Public Sans + fontSize: 16px + fontWeight: '400' + lineHeight: 24px + label-bold: + fontFamily: Public Sans + fontSize: 14px + fontWeight: '700' + lineHeight: 20px + label-sm: + fontFamily: Public Sans + fontSize: 12px + fontWeight: '500' + lineHeight: 16px +rounded: + sm: 0.25rem + DEFAULT: 0.5rem + md: 0.75rem + lg: 1rem + xl: 1.5rem + full: 9999px +spacing: + touch-min: 48px + touch-lg: 56px + margin-mobile: 16px + gutter: 12px + stack-sm: 8px + stack-md: 16px + stack-lg: 24px +--- + +## Brand & Style +The design system for the Rider/Agent application shifts from the consumer-facing "discovery" experience to a high-utility "execution" environment. The brand personality is functional, clear, and urgent, designed for users who are often in motion, outdoors, and multitasking. + +The design style is **Corporate / Modern** with a heavy emphasis on **High-Contrast** elements to ensure legibility under direct sunlight. We maintain brand continuity through the signature red primary color and specific rounding constants, but the interface prioritizes information density and clear calls-to-action over decorative whitespace. The emotional response should be one of confidence, reliability, and efficiency. + +## Colors +The palette is engineered for maximum outdoor readability. +- **Primary Red (#c01227):** Reserved for critical actions, branding, and primary buttons. +- **Secondary Dark (#1a1a1a):** Used for primary text and high-contrast UI shells to ensure the "urgent" tone. +- **Status Colors:** Explicitly mapped to operational states: + - **Success Green:** Completion of Pick up or successful check-ins. + - **Warning Amber:** Delays, expiration of tasks, or inventory issues. + - **Info Blue:** Navigation prompts, route updates, and general system notifications. +- **Backgrounds:** A clean, high-brightness neutral palette to provide a stark contrast against the dark text and vibrant status colors. + +## Typography +This design system utilizes **Public Sans** across all levels to maintain its institutional and trustworthy character. Typography is optimized for glanceability: +- **Weight over Scale:** We use semi-bold and bold weights for critical data points (Booking numbers, street names) rather than just increasing size. +- **Touch-Friendly Heights:** Line heights are slightly more generous than standard web defaults to ensure that text-based interactive elements have a sufficient vertical hit area. +- **Mobile Priority:** Large display styles are used sparingly, primarily for "Current Task" headers. + +## Layout & Spacing +The layout follows a **Fluid Grid** model with a strict 4px baseline rhythm. +- **Touch Targets:** A minimum height of 48px is enforced for all interactive elements, with 56px used for primary action buttons to accommodate gloved hands or rapid movement. +- **Safe Zones:** A 16px horizontal margin is standard on mobile. Content cards use 12px internal padding to maximize information density while maintaining legibility. +- **Vertical Stacking:** Information is organized in a "stack" layout (Top-to-Bottom) to simplify the decision-making process for the rider. + +## Elevation & Depth +To maintain brand consistency while ensuring utility, the design system employs **Ambient Shadows**: +- **Soft Shadows:** Shadows use a low-opacity (#000000 at 8-10%) with a wide blur (12px to 16px) and a slight Y-offset. This creates a subtle lift for cards without cluttering the UI. +- **Active States:** Buttons and cards use a deeper, more condensed shadow when pressed to provide tactile feedback. +- **Contrast Borders:** In high-glare environments, shadows are reinforced with a 1px low-contrast border (#E0E0E0) to ensure the container boundaries are visible. + +## Shapes +The shape language is defined by a consistent **14px (0.875rem) corner radius** for all primary containers and buttons. +- **Standard Corners:** 14px for cards, primary buttons, and input fields. +- **Small Elements:** 8px for inner nested elements or secondary tags. +- **Pill Shapes:** Used exclusively for status badges (e.g., "Active", "On Time") to distinguish them from actionable buttons. + +## Components +- **Primary Action Buttons:** 56px height, Solid Red (#c01227) with White text. Bold weight. Rounded to 14px. +- **Operational Cards:** White background with a subtle shadow and 1px border. Used for individual Booking details. Headers within cards should use the "Label-Bold" style in Red or Dark Grey. +- **Status Chips:** High-saturation background (Success Green, Warning Amber, Info Blue) with white or high-contrast dark text. Pill-shaped. +- **Lists:** High-density rows with 56px minimum height. Use chevron icons to indicate drill-down actions. +- **Input Fields:** 14px rounding, 56px height. Labels are persistent above the field in "Label-SM" style for clarity during data entry. +- **Progress Indicators:** Linear bars at the top of the screen or within task cards to show "Steps Remaining" in a pickup flow. +- **Floating Action Buttons (FAB):** Used sparingly for "Center on Map" or "Emergency Contact" to ensure they don't block critical pickup data. \ No newline at end of file diff --git a/refer/rider_tasks/screen.png b/refer/rider_tasks/screen.png new file mode 100644 index 0000000..ca40611 --- /dev/null +++ b/refer/rider_tasks/screen.png @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/release-key.jks b/release-key.jks new file mode 100644 index 0000000..bd3a1a9 Binary files /dev/null and b/release-key.jks differ diff --git a/test/accepted_pill_test.dart b/test/accepted_pill_test.dart new file mode 100644 index 0000000..da978d6 --- /dev/null +++ b/test/accepted_pill_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/home/accepted_pill.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; + +/// The way from Home to the work used to be a "Continue trip" button under the +/// last hub row of a trip — so it only existed once that trip had nothing left +/// to decide, and it was below the fold on any route longer than about four +/// 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 { + tester.view.physicalSize = const Size(1170, 2532); + 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: AcceptedPill(count: count, onTap: onTap ?? () {}), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('says nothing when nothing is accepted', (tester) async { + await pump(tester, 0); + expect(find.textContaining('accepted'), findsNothing); + }); + + testWidgets('carries the count and the singular wording', (tester) async { + await pump(tester, 1); + expect(find.text('1'), findsOneWidget); + expect(find.text('booking accepted'), findsOneWidget); + }); + + testWidgets('pluralises', (tester) async { + await pump(tester, 4); + expect(find.text('4'), findsOneWidget); + expect(find.text('bookings accepted'), findsOneWidget); + }); + + testWidgets('is a tap target that leads somewhere', (tester) async { + var taps = 0; + await pump(tester, 3, onTap: () => taps++); + + await tester.tap(find.text('bookings accepted')); + await tester.pumpAndSettle(); + expect(taps, 1); + + // Material's touch-target floor. The pill is small on purpose — it is a way + // through, not the action Home exists for — so this is the guard that keeps + // "small" from becoming "hard to hit at arm's length". + final height = tester.getSize(find.byType(InkWell)).height; + expect(height, greaterThanOrEqualTo(48.0)); + expect(ButtonSizes.minTapTarget, lessThanOrEqualTo(height)); + }); +} diff --git a/test/active_pickup_banner_test.dart b/test/active_pickup_banner_test.dart new file mode 100644 index 0000000..44338d7 --- /dev/null +++ b/test/active_pickup_banner_test.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/home/homepage_banner.dart'; +import 'package:miler/widget/Bottom_page.dart'; + +/// The live strip at the foot of Home and Bookings. +/// +/// ── The bug being pinned ── +/// +/// Both pages pinned it to `bottom: 0`, and the nav bar covered it. The bar +/// *floats* — a `Positioned` pill inside the shell's `Stack` rather than a +/// `bottomNavigationBar` — so the Scaffold reserves no room for it and +/// `SafeArea` cannot see it. Nothing in the type system says "you are about to +/// draw underneath the navigation", which is why it survived on two screens at +/// once, so it is measured here instead. +void main() { + const safeBottom = 34.0; // a home-indicator phone, where it is worst + + Future pump( + WidgetTester tester, { + double width = 390, + double textScale = 1.0, + int count = 1, + String address = '19, Thadagam Road, Venkitapuram, Coimbatore 641025', + }) async { + tester.view.physicalSize = Size(width * 3, 844 * 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( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith( + padding: const EdgeInsets.only(bottom: safeBottom), + textScaler: TextScaler.linear(textScale), + ), + child: child!, + ), + home: Scaffold( + body: Stack( + children: [ + const Positioned.fill(child: ColoredBox(color: Colors.white)), + // Exactly how both pages mount it. + Positioned( + left: 0, + right: 0, + bottom: 0, + child: ActivePickupBanner( + activePickups: [ + for (var i = 0; i < count; i++) + { + 'orderid': 'ORD${1000 + i}', + 'pickupaddress': address, + 'orderstatus': 'arrived', + }, + ], + onTap: (_) {}, + ), + ), + ], + ), + ), + ), + ), + ); + // Not `pumpAndSettle`: the LIVE dot pulses forever by design, so nothing on + // this widget ever settles. Two pumps past the longest transition is enough. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + + /// 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; + return screen - tester.getRect(find.byType(Material).last).bottom; + } + + 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.'); + }); + + testWidgets('sits at one height rather than chasing the bar', (tester) async { + // The banner used to follow a `navVisible` notifier down when the bar slid + // off on scroll and back up when it returned. The bar compacts now instead + // of leaving, so there is no state in which that room is free — and a + // banner that re-laid itself out on every change of scroll direction was + // moving for a 20pt gap the rider would never have noticed. + await pump(tester); + final resting = gapBelowCard(tester); + + await tester.pump(const Duration(milliseconds: 400)); + 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'); + }); + + 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. + expect(card.left, closeTo(16.w, 1)); + expect(390 - card.right, closeTo(16.w, 1)); + }); + + group('lays out without overflowing', () { + 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 { + // A long address plus a plural title is the widest this row ever gets, + // and the title used to sit unflexed beside a fixed-width LIVE chip. + await pump( + tester, + width: width, + textScale: scale, + count: 3, + 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'); + }); + } + } + }); + + testWidgets('says nothing when there is nothing running', (tester) async { + await pump(tester, count: 0); + expect(find.text('Active Pickup'), findsNothing); + expect(find.text('LIVE'), findsNothing); + }); + + 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'); + }); +} diff --git a/test/activity_list_test.dart b/test/activity_list_test.dart new file mode 100644 index 0000000..0547c9d --- /dev/null +++ b/test/activity_list_test.dart @@ -0,0 +1,199 @@ +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/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'; + +/// ── A day's work is a list, not a stack of cards ── +/// +/// Every finished stop was a filled, rounded card with 12pt of air under it, +/// and inside each one a *second*, white panel holding the three figures. So the +/// page came down the screen as grey slabs with white slabs punched into them — +/// the card-inside-card shape, repeated once per stop — and on top of that every +/// row opened with a 40pt green disc that was identical on all of them. +/// +/// Finished stops 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. +void main() { + String day(DateTime t) => + '${t.year}-${t.month.toString().padLeft(2, '0')}-' + '${t.day.toString().padLeft(2, '0')}'; + + Map finished( + String id, + String name, + String address, { + required String type, + double cash = 0, + int minsAgo = 30, + }) { + final now = DateTime.now(); + final at = now.subtract(Duration(minutes: minsAgo)); + return { + 'orderid': id, + 'type': type, + 'pickupcustomer': name, + 'pickupaddress': address, + 'orderstatus': 'picked', + 'completedday': day(now), + 'completedat': at.toIso8601String(), + 'startedat': at.subtract(const Duration(minutes: 14)).toIso8601String(), + if (cash > 0) 'collectionamt': '$cash', + 'proof': {if (cash > 0) 'amountcollected': cash}, + }; + } + + Future pumpActivity(WidgetTester tester) async { + SharedPreferences.setMockInitialValues({ + 'userId': 1, + 'completed_bookings': jsonEncode([ + finished( + 'DM240817', + 'Sundar Textiles', + '78, Cross Cut Road, Gandhipuram, Coimbatore', + type: 'combined', + cash: 2450, + minsAgo: 22, + ), + finished( + 'DM240816', + 'Green Leaf Grocers', + '29, Kovaipudur Main Road, Coimbatore', + type: 'delivery', + minsAgo: 68, + ), + ]), + }); + + 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.pumpAndSettle(const Duration(seconds: 2)); + } + + testWidgets('a finished stop is not a card', (tester) async { + await pumpActivity(tester); + expect(find.text('Sundar Textiles'), findsOneWidget); + + // `cardSurface` was the row fill and `pureSurface` the fill of the figure + // panel nested inside it. Neither may come back: on a white page a filled + // row is a box round a list item, and a filled panel inside a filled row is + // the shape this page was rebuilt to remove. + final fills = tester + .widgetList(find.byType(Container)) + .map((c) => c.decoration) + .whereType() + .map((d) => d.color) + .toList(); + + expect(fills, isNot(contains(ColorConstants.cardSurface))); + }); + + testWidgets('the figures line up across rows, not just within one', ( + 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); + + 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)); + + // 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.) + expect(find.text('P&D'), 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)); + } + }); + + testWidgets('the four tabs agree on what colour the paper is', ( + tester, + ) async { + // 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 = []; + void visit(Element e) { + final w = e.widget; + if (w is Scaffold) scaffolds.add(w); + e.visitChildren(visit); + } + + WidgetsBinding.instance.rootElement!.visitChildren(visit); + return scaffolds.isEmpty ? null : scaffolds.first.backgroundColor; + } + + SharedPreferences.setMockInitialValues({'userId': 1}); + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + for (final page in [const ActivityPage(), const ProfilePage()]) { + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp(home: page), + ), + ); + await tester.pump(); + expect(bgOf(page), ColorConstants.pureSurface); + } + }); +} diff --git a/test/activity_refresh_test.dart b/test/activity_refresh_test.dart new file mode 100644 index 0000000..5c6cb66 --- /dev/null +++ b/test/activity_refresh_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/widget/Bottom_page.dart'; + +/// The shell keeps every tab alive in an `IndexedStack`: all four pages are +/// built at launch and switching tabs only changes which one is painted. So a +/// page that loads in `initState` shows whatever was true before the rider did +/// anything — which is exactly how a completed stop failed to appear on +/// Activity. Pages re-read when their index comes forward, so the notifier that +/// tells them has to actually change. +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')); + }); + + await tester.pumpWidget( + MaterialApp( + home: IndexedStack(index: 0, children: [page(0), page(1), page(2)]), + ), + ); + + expect( + built, + containsAll([0, 1, 2]), + reason: 'this is why initState cannot be a page\'s only load', + ); + }); + + test('the shell publishes the tab on screen', () { + // Starts on Home and is a broadcast point a page can listen to. + var heard = []; + void listener() => heard.add(BottomPage.currentTab.value); + + BottomPage.currentTab.addListener(listener); + addTearDown(() => BottomPage.currentTab.removeListener(listener)); + + BottomPage.currentTab.value = 2; + BottomPage.currentTab.value = 1; + + expect(heard, [2, 1]); + }); +} diff --git a/test/app_bar_glass_test.dart b/test/app_bar_glass_test.dart new file mode 100644 index 0000000..5f12077 --- /dev/null +++ b/test/app_bar_glass_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; +import 'package:miler/views/helpers/widgets/miler_app_bar.dart'; + +/// Two rules the bars now share, and both are the kind that quietly comes +/// undone the next time someone adds a screen: +/// +/// • **One line.** The bar carries the page's name and nothing under it. Every +/// caption that used to sit there duplicated a figure the page itself showed +/// louder and lower down, and two sources for one number is how they drift. +/// +/// • **One surface.** A frosted brand wash, not a solid slab, so a tab and a +/// pushed page read as the same app at the top of the screen. +void main() { + Future pump(WidgetTester tester, Widget page) async { + tester.view.physicalSize = const Size(390 * 3, 844 * 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(home: page), + ), + ); + await tester.pumpAndSettle(); + } + + group('MilerAppBar', () { + testWidgets('shows the page name and nothing beneath it', (tester) async { + await pump(tester, const Scaffold(appBar: MilerAppBar(title: 'Activity'))); + + expect(find.text('Activity'), findsOneWidget); + expect( + tester.widgetList(find.byType(Text)), + hasLength(1), + reason: 'the caption under the title is gone from every bar', + ); + }); + + testWidgets('wears the frosted brand wash, not a solid fill', + (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); + }); + + testWidgets('closes with no rule under it', (tester) async { + 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 — + // the same doubled-up border this app has been pulling out of its cards. + final bar = tester.widget(find.byType(AppBar)); + expect(bar.bottom, isNull); + }); + + testWidgets('keeps a trailing slot for the pages that need one', + (tester) async { + await pump( + tester, + Scaffold( + appBar: MilerAppBar( + title: 'My Bookings', + trailing: const Icon(Icons.map_rounded), + ), + ), + ); + expect(find.byIcon(Icons.map_rounded), findsOneWidget); + }); + }); + + group('appTopBar', () { + testWidgets('uses the same wash 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'); + }); + }); + + 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. + 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', + ); + }); +} diff --git a/test/assignment_lookup_test.dart b/test/assignment_lookup_test.dart new file mode 100644 index 0000000..f1a55b1 --- /dev/null +++ b/test/assignment_lookup_test.dart @@ -0,0 +1,122 @@ +import 'package:flutter_test/flutter_test.dart'; +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, +) => + { + 'bookingassignmentid': assignmentId, + 'bookingid': bookingId, + 'mileruserid': 42, + 'assignmentstatus': status, + }; + +void main() { + group('AssignmentLookup.buildIndex', () { + test('maps bookingid to bookingassignmentid, not to itself', () { + final index = AssignmentLookup.buildIndex([row(9001, 5, 'Assigned')]); + + // The whole point: booking 5 must resolve to assignment 9001. Returning + // the booking id here is the 404 bug this class exists to prevent. + expect(index['5'], 9001); + }); + + test('keeps the newest row when a booking has only terminal history', () { + // assignedat DESC — newest first. + final index = AssignmentLookup.buildIndex([ + row(9003, 7, 'Rejected'), + row(9002, 7, 'Cancelled'), + ]); + + expect(index['7'], 9003); + }); + + test('prefers a live assignment over a newer terminal one', () { + // Reassignment history: the Rejected row is newer, but the rider must act + // on the still-live Assigned row. + final index = AssignmentLookup.buildIndex([ + row(9010, 8, 'Rejected'), + row(9009, 8, 'Assigned'), + ]); + + expect(index['8'], 9009); + }); + + test('does not let a later terminal row displace a live one', () { + final index = AssignmentLookup.buildIndex([ + row(9020, 9, 'Assigned'), + row(9019, 9, 'Rejected'), + ]); + + 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'), + ]); + + expect(index['11'], 9030); + }); + + test('normalises int and string booking ids to the same key', () { + final index = AssignmentLookup.buildIndex([ + row(9040, 12, 'Assigned'), + row(9041, '13', 'Assigned'), + ]); + + expect(index['12'], 9040); + expect(index['13'], 9041); + }); + + test('coerces a string assignment id to int', () { + final index = AssignmentLookup.buildIndex([ + { + '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' + }, + ]); + + expect(index, isEmpty); + }); + + test('ignores non-map entries', () { + final index = AssignmentLookup.buildIndex([ + 'nonsense', + 42, + null, + row(9070, 16, 'Assigned'), + ]); + + expect(index, {'16': 9070}); + }); + + test('handles an empty list', () { + expect(AssignmentLookup.buildIndex([]), isEmpty); + }); + }); +} diff --git a/test/auth_design_test.dart b/test/auth_design_test.dart new file mode 100644 index 0000000..c28f9d9 --- /dev/null +++ b/test/auth_design_test.dart @@ -0,0 +1,353 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/helpers/constants/app_theme.dart'; +import 'package:miler/views/helpers/constants/design_constants.dart'; +import 'package:miler/views/onboardscreens/auth_scaffold.dart'; + +/// The onboarding flow's shared frame. These pin the two things that were +/// actually broken in the five hand-rolled versions it replaced: code boxes +/// sized in fixed `.w` units inside a `spaceBetween` row, and a CTA inheriting +/// the app theme's full-width-form metrics. +void main() { + Future pump( + WidgetTester tester, + Widget child, { + Size screen = const Size(390, 844), + }) async { + tester.view.physicalSize = Size(screen.width * 3, screen.height * 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( + theme: AppTheme.light, + home: child, + ), + ), + ); + await tester.pumpAndSettle(); + } + + Widget codeRow(int count, {bool error = false}) { + final controllers = + List.generate(count, (_) => TextEditingController()); + final nodes = List.generate(count, (_) => FocusNode()); + return Scaffold( + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: AuthCodeRow( + controllers: controllers, + nodes: nodes, + hasError: error, + onChanged: (_, _) {}, + ), + ), + ); + } + + group('AuthCodeRow', () { + // The old OTP row hard-coded 46.w boxes and the MPIN rows hard-coded 70.w, + // inside `MainAxisAlignment.spaceBetween`. That only fits because the design + // 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 { + await pump(tester, codeRow(count), screen: Size(width, 780)); + expect(tester.takeException(), isNull); + expect(find.byType(TextField), findsNCount(count)); + + // Every box inside the 24pt gutters, none overlapping. + final boxes = tester + .widgetList(find.byType(TextField)) + .toList(); + expect(boxes.length, count); + + final rects = find.byType(TextField).evaluate().map((e) { + final ro = e.renderObject! as RenderBox; + final pos = ro.localToGlobal(Offset.zero); + return Rect.fromLTWH(pos.dx, pos.dy, ro.size.width, ro.size.height); + }).toList(); + + for (final r in rects) { + expect(r.left, greaterThanOrEqualTo(-0.01)); + expect(r.right, lessThanOrEqualTo(width + 0.01)); + expect(r.width, greaterThan(0)); + } + }); + } + } + + testWidgets('an error turns the boxes red', (tester) async { + await pump(tester, codeRow(6, error: true)); + expect(tester.takeException(), isNull); + }); + }); + + group('AuthPrimaryButton', () { + 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), + ), + ), + ); + expect(tester.takeException(), isNull); + + final button = tester.getRect(find.byType(ElevatedButton)); + final icon = tester.getRect(find.byType(Icon)); + final text = tester.getRect(find.text('Verify & Continue')); + + // The app theme's `vertical: 18` padding would leave a 20pt content band + // in a 56pt button and crush both of these; see AuthPrimaryButton's note. + expect(icon.height, greaterThan(14)); + expect(text.height, greaterThan(12)); + for (final part in [icon, text]) { + expect(part.top, greaterThanOrEqualTo(button.top - 0.01)); + expect(part.bottom, lessThanOrEqualTo(button.bottom + 0.01)); + } + }); + + testWidgets('a null callback disables it', (tester) async { + await pump( + tester, + const Scaffold( + body: Padding( + padding: EdgeInsets.all(24), + child: AuthPrimaryButton(label: 'Continue', onPressed: null), + ), + ), + ); + final b = tester.widget(find.byType(ElevatedButton)); + expect(b.onPressed, isNull); + }); + }); + + group('AuthScaffold', () { + testWidgets('pins the footer below the scrolling body', (tester) async { + await pump( + tester, + AuthScaffold( + onBack: _noop, + step: 2, + title: 'Enter the 6-digit code', + subtitle: 'Sent by SMS.', + footer: const AuthPrimaryButton(label: 'Verify', onPressed: _noop), + children: [Container(height: 200, color: Colors.white)], + ), + ); + expect(tester.takeException(), isNull); + + expect(find.text('STEP 2 OF 3'), findsOneWidget); + expect(find.text('Enter the 6-digit code'), findsOneWidget); + + 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'); + }); + + testWidgets('the back target clears the 44dp floor', (tester) async { + await pump( + tester, + AuthScaffold( + onBack: _noop, + title: 'T', + footer: const AuthPrimaryButton(label: 'Go', onPressed: _noop), + children: const [], + ), + ); + final back = tester.getRect(find.byIcon(Icons.arrow_back_rounded)); + // 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), + )); + 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 { + await pump( + tester, + AuthScaffold( + title: 'Enter your MPIN', + footer: const AuthPrimaryButton(label: 'Unlock', onPressed: _noop), + children: const [], + ), + ); + expect(find.textContaining('STEP'), findsNothing); + }); + + // ── The banner is opt-in ── + // + // Only Sign In passes one. The other three screens in the flow must keep + // the plain top-anchored frame, because a brand block there would spend + // keyboard room on a screen whose whole job is to accept six digits. + testWidgets('no banner unless a screen asks for one', (tester) async { + await pump( + tester, + AuthScaffold( + title: 'Enter the 6-digit code', + footer: const AuthPrimaryButton(label: 'Verify', onPressed: _noop), + children: const [], + ), + ); + expect(find.byType(AuthBrandBanner), findsNothing); + }); + }); + + /// ── The brand block on Sign In ── + /// + /// The two things that can actually break here: the block eating the field on + /// a short screen with the keyboard up, and the wordmark being squashed by a + /// 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)], + ); + + for (final width in [320.0, 360.0, 390.0, 430.0]) { + testWidgets('lays out on a ${width.toInt()}pt screen', (tester) async { + await pump(tester, bannered(), screen: Size(width, 720)); + expect(tester.takeException(), isNull); + expect(find.byType(AuthBrandBanner), findsOneWidget); + expect(find.text('MILER · RIDER APP'), findsOneWidget); + + // 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'); + }); + } + + 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'), + )); + // 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 { + await pump(tester, bannered()); + final atRest = tester.getRect(find.byType(AuthBrandBanner)).height; + + // A keyboard is a bottom view inset; that is the only signal the widget + // reads, so it is the only one the test needs to fake. + // + // This is also the regression guard for a real bug: `Scaffold` with + // `resizeToAvoidBottomInset` strips the bottom inset from everything in + // its `body`, so the banner saw zero however high the keyboard was. It + // only works because AuthScaffold re-provides its own MediaQuery. + tester.view.viewInsets = FakeViewPadding(bottom: 300 * 3); + addTearDown(tester.view.resetViewInsets); + 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'); + // And the mark is still there — it shrinks, it does not disappear. + expect(find.byType(AuthBrandBanner), findsOneWidget); + }); + + // The screens that focus a box on the first frame must not render the full + // block and then animate it away — that is the deleted splash, rebuilt. + testWidgets('compact starts small and stays small', (tester) async { + await pump( + tester, + AuthScaffold( + banner: const AuthBrandBanner(compact: true), + title: 'Enter your MPIN', + footer: const AuthPrimaryButton(label: 'Unlock', onPressed: _noop), + children: const [], + ), + ); + final first = tester.getRect(find.byType(AuthBrandBanner)).height; + + tester.view.viewInsets = FakeViewPadding(bottom: 300 * 3); + addTearDown(tester.view.resetViewInsets); + await tester.pumpAndSettle(); + + 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 ── + // + // The plain layout renders `onBack` in its own row above the content. The + // bannered one has no such row, so a screen passing `onBack` lost its only + // way out — which is three of the four screens in this flow. + testWidgets('back survives the banner', (tester) async { + await pump( + tester, + AuthScaffold( + onBack: _noop, + banner: const AuthBrandBanner(compact: true), + title: 'Enter the 6-digit code', + footer: const AuthPrimaryButton(label: 'Verify', onPressed: _noop), + children: const [], + ), + ); + expect(find.byIcon(Icons.arrow_back_rounded), findsOneWidget); + + final target = tester.getRect(find.ancestor( + of: find.byIcon(Icons.arrow_back_rounded), + matching: find.byType(InkResponse), + )); + expect(target.width, greaterThanOrEqualTo(ButtonSizes.minTapTarget)); + expect(target.height, greaterThanOrEqualTo(ButtonSizes.minTapTarget)); + + // On the banner, not below it. + final banner = tester.getRect(find.byType(AuthBrandBanner)); + expect(target.center.dy, lessThan(banner.bottom)); + }); + }); + + group('AuthInlineError', () { + testWidgets('takes no space when there is nothing to say', (tester) async { + await pump( + tester, + const Scaffold(body: Center(child: AuthInlineError())), + ); + expect(tester.getSize(find.byType(AuthInlineError)).height, 0); + }); + + testWidgets('shows the message when there is', (tester) async { + await pump( + tester, + const Scaffold( + body: Center(child: AuthInlineError(message: 'That code is wrong.')), + ), + ); + expect(find.text('That code is wrong.'), findsOneWidget); + expect(tester.getSize(find.byType(AuthInlineError)).height, + greaterThan(0)); + }); + }); +} + +void _noop() {} + +/// `findsNWidgets` reads badly inline at these call sites. +Matcher findsNCount(int n) => findsNWidgets(n); diff --git a/test/bookings_trip_label_test.dart b/test/bookings_trip_label_test.dart new file mode 100644 index 0000000..7272470 --- /dev/null +++ b/test/bookings_trip_label_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/home/trip.dart'; + +/// Bookings shows one flat list — no trip tabs, deliberately — so the only +/// thing telling the rider which of the day's runs he is on is the label above +/// it. The number has to mean what the hub means by it, and that is the part +/// worth pinning down: the numbering is against the WHOLE day, not against the +/// subset of it the rider happened to accept. +/// +/// This exercises the same `groupIntoTrips` + positional numbering the screen +/// uses, which is the piece that can silently go wrong. +void main() { + Map stop(String id, String slotFrom, String slotTo) => + { + 'orderid': id, + 'pickupcustomer': 'Shop $id', + 'slotstarttime': slotFrom, + 'slotendtime': slotTo, + }; + + /// Order id → 1-based trip number, the way `_numberTrips` builds it. + Map numberTrips(List> allToday) { + final byId = >{}; + for (final s in allToday) { + final id = (s['orderid'] ?? '').toString(); + if (id.isEmpty) continue; + byId.putIfAbsent(id, () => s); + } + final trips = Trip.groupIntoTrips(byId.values.toList()); + return { + for (var i = 0; i < trips.length; i++) + for (final id in trips[i].orderIds) id: i + 1, + }; + } + + final morning = [ + 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'), + ]; + + test('trips are numbered by their slot, earliest first', () { + final numbers = numberTrips([...afternoon, ...morning]); + + expect(numbers['A'], 1); + expect(numbers['B'], 1); + expect(numbers['C'], 2); + }); + + test('a rider who took only the afternoon is still on Trip 2', () { + // ── The reason this is computed from the day and not from the queue ── + // + // Numbering only the accepted stops would call this run "Trip 1", while the + // hub, Home and the rider's own supervisor all call it Trip 2. The label is + // a shared name or it is worse than nothing. + final dayNumbers = numberTrips([...morning, ...afternoon]); + expect(dayNumbers['C'], 2); + + final queueOnly = numberTrips(afternoon); + expect( + queueOnly['C'], + 1, + reason: 'this is exactly the wrong answer the screen must not give', + ); + }); + + test('a stop appearing twice in one fetch does not split its trip', () { + // The same stop arrives from up to three sources per fetch — v1, v3 and the + // local accepted store — and each copy carries the same slot. + final numbers = numberTrips([...morning, ...morning, ...afternoon]); + + expect(numbers['A'], 1); + expect(numbers['C'], 2); + }); + + test('a day with one slot is Trip 1, not "your route"', () { + expect(numberTrips(morning)['A'], 1); + }); +} diff --git a/test/bottom_nav_compact_test.dart b/test/bottom_nav_compact_test.dart new file mode 100644 index 0000000..52de876 --- /dev/null +++ b/test/bottom_nav_compact_test.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The floating nav bar used to slide off the bottom of the screen as the rider +/// moved down a list and come back when he reversed. That buys 66pt of list and +/// costs him the ability to change tab at all until he scrolls the other way — +/// on a long Activity list the only route out is a gesture he has to know +/// about. A control that vanishes is not a smaller control; it is a missing one. +/// +/// It compacts instead: labels collapse, icons draw in, the pill narrows and +/// shortens, and every target stays on screen. This is Instagram's iOS tab bar. +/// +/// `BottomPage` builds four real tab pages (Get controllers, SharedPreferences, +/// Geolocator, live fetches) and cannot be pumped in a unit test, so — as in +/// `card_stripe_layout_test.dart` — these pin the *idiom* rather than the +/// widget. Two halves, only one of them obvious: +/// +/// • A `NotificationListener` accumulates `scrollDelta` +/// from `ScrollUpdateNotification`. Deliberately not `UserScrollNotification`: +/// that fires when a drag *begins*, when `metrics.pixels` has barely moved, +/// so a distance threshold measured there compares an offset against itself +/// and never fires at all. +/// +/// • The label collapses via `Align(heightFactor:)` inside a `ClipRect`, not a +/// fade. Fading alone leaves the label's height behind, so the bar cannot +/// actually get shorter — it shrinks around a blank line. +void main() { + const barKey = ValueKey('nav'); + const labelKey = ValueKey('label'); + + const double resting = 66; + const double compactHeight = 46; + + /// The shell shape, with the same wiring `BottomPage` uses. + Widget shell({ + required ValueNotifier compact, + required ValueNotifier accum, + bool scrollable = true, + }) { + return MaterialApp( + home: ValueListenableBuilder( + valueListenable: compact, + builder: (context, k, _) => Scaffold( + body: NotificationListener( + onNotification: (n) { + if (n.metrics.axis != Axis.vertical) return false; + if (n.metrics.maxScrollExtent <= 0) { + accum.value = 0; + compact.value = 0; + return false; + } + 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; + if (delta > 0 && compact.value == 0 && accum.value > 24) { + accum.value = 0; + compact.value = 1; + } else if (delta < 0 && compact.value == 1 && accum.value < -6) { + accum.value = 0; + compact.value = 0; + } + return false; + }, + child: ListView( + children: [SizedBox(height: scrollable ? 3000 : 10)], + ), + ), + bottomNavigationBar: SizedBox( + key: barKey, + height: resting + (compactHeight - resting) * k, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.home_rounded, size: 21), + ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: (1 - k).clamp(0.0, 1.0), + child: Opacity( + opacity: (1 - k * 1.8).clamp(0.0, 1.0), + child: const Text('Home', key: labelKey), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + double barHeight(WidgetTester tester) => + tester.getSize(find.byKey(barKey)).height; + + 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); + addTearDown(accum.dispose); + await tester.pumpWidget(shell(compact: compact, accum: accum)); + + expect(barHeight(tester), resting); + + // Finger bottom → up: the list scrolls forward, the bar draws in. + await tester.drag(find.byType(ListView), const Offset(0, -300)); + await tester.pumpAndSettle(); + expect(barHeight(tester), compactHeight); + + // Finger top → down: he is heading back, so it returns to full size. + await tester.drag(find.byType(ListView), const Offset(0, 300)); + await tester.pumpAndSettle(); + expect(barHeight(tester), resting); + }); + + 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); + final accum = ValueNotifier(0.0); + addTearDown(compact.dispose); + addTearDown(accum.dispose); + await tester.pumpWidget(shell(compact: compact, accum: accum)); + + 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'); + } + }); + + testWidgets('the label collapses rather than merely fading', (tester) async { + // A fade alone leaves the label's height behind, so the bar would shrink + // around a blank line and never reach its compact height. + final compact = ValueNotifier(0.0); + final accum = ValueNotifier(0.0); + addTearDown(compact.dispose); + addTearDown(accum.dispose); + await tester.pumpWidget(shell(compact: compact, accum: accum)); + + final labelled = tester.getSize(find.byKey(labelKey)).height; + expect(labelled, greaterThan(0)); + + await tester.drag(find.byType(ListView), const Offset(0, -300)); + await tester.pumpAndSettle(); + + // 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, + ); + 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 { + 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.drag(find.byType(ListView), const Offset(0, -300)); + await tester.pumpAndSettle(); + expect(barHeight(tester), resting); + }); + + testWidgets('a horizontal scroll leaves the bar alone', (tester) async { + // Tab strips and carousels scroll sideways; the nav bar is not theirs. + final compact = ValueNotifier(0.0); + addTearDown(compact.dispose); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: NotificationListener( + onNotification: (n) { + if (n.metrics.axis != Axis.vertical) return false; + compact.value = 1; + return false; + }, + child: ListView( + scrollDirection: Axis.horizontal, + children: [Container(width: 3000)], + ), + ), + ), + ), + ); + + await tester.drag(find.byType(ListView), const Offset(-300, 0)); + await tester.pumpAndSettle(); + expect(compact.value, 0); + }); +} diff --git a/test/bottom_nav_indicator_test.dart b/test/bottom_nav_indicator_test.dart new file mode 100644 index 0000000..bdc03c3 --- /dev/null +++ b/test/bottom_nav_indicator_test.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The selection pill travels between tabs instead of teleporting. +/// +/// ── The bug ── +/// +/// The pill was part of each tab: an `AnimatedContainer` per item whose fill +/// went from transparent to red over 280ms. Going Account → Home therefore faded +/// a pill out on the far right while a *second* pill faded in on the far left. +/// Nothing ever occupied the space between them, so there was no way for it to +/// pass over Bookings and Earnings — the highlight simply appeared at the +/// destination. +/// +/// There is now one pill, owned by the bar, positioned by an animated value in +/// tab units. These tests measure where it actually is, frame by frame. +/// +/// ── Why it is replicated rather than pumped ── +/// +/// As in `bottom_nav_autohide_test.dart`: `BottomPage` builds four real tab +/// pages (Get controllers, SharedPreferences, Geolocator, live fetches) and +/// cannot be pumped headless. So this pins the *mechanism* — the same +/// `Stack` + `PositionedDirectional(start: slot * pos)` + single interrupted +/// `Tween`, at the same 380ms and `easeInOutCubic` — and asserts the properties +/// the real bar has to have. +void main() { + const pillKey = ValueKey('pill'); + const tabs = 4; + const barWidth = 400.0; + const slot = barWidth / tabs; + const duration = Duration(milliseconds: 380); + + /// Mirrors `_BottomPageState`'s pill wiring. + Widget bar({required ValueNotifier selected, required int initial}) { + return MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: barWidth, + height: 66, + child: _PillBar( + key: const ValueKey('bar'), + selected: selected, + initial: initial, + pillKey: pillKey, + tabs: tabs, + duration: duration, + ), + ), + ), + ), + ); + } + + double pillLeft(WidgetTester tester) => + tester.getTopLeft(find.byKey(pillKey)).dx - + tester.getTopLeft(find.byKey(const ValueKey('bar'))).dx; + + testWidgets('at rest the pill sits on the selected tab', (tester) async { + for (final start in [0, 1, 2, 3]) { + final selected = ValueNotifier(start); + addTearDown(selected.dispose); + // Tear the tree down between iterations: the bar carries a fixed key, so + // pumping straight over it keeps the old State and `initial` — read once + // in `initState` — would never be re-applied. + 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'); + } + }); + + 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); + await tester.pumpWidget(bar(selected: selected, initial: 3)); + await tester.pumpAndSettle(); + expect(pillLeft(tester), closeTo(slot * 3, 0.5)); + + selected.value = 0; + await tester.pump(); + + // Sample the whole 380ms. + final samples = []; + for (var elapsed = 0; elapsed < 400; elapsed += 20) { + await tester.pump(const Duration(milliseconds: 20)); + samples.add(pillLeft(tester)); + } + + // It must actually have been over Earnings (slot 2) and Bookings (slot 1) + // 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'); + + // 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'); + } + + await tester.pumpAndSettle(); + expect(pillLeft(tester), closeTo(0, 0.5), reason: 'it must arrive on Home'); + }); + + testWidgets('a tap mid-slide resumes from where the pill is', (tester) async { + // Rebuilding the tween from `selected` rather than from the pill's live + // position would snap it back to Account and re-run the entire journey. + final selected = ValueNotifier(3); + addTearDown(selected.dispose); + await tester.pumpWidget(bar(selected: selected, initial: 3)); + await tester.pumpAndSettle(); + + selected.value = 0; + await tester.pump(); + await tester.pump(const Duration(milliseconds: 190)); + final midway = pillLeft(tester); + expect(midway, greaterThan(slot * 0.5)); + expect(midway, lessThan(slot * 2.5)); + + // Change your mind, back towards Account. + 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}'); + + await tester.pumpAndSettle(); + expect(pillLeft(tester), closeTo(slot * 2, 0.5)); + }); + + testWidgets('the tabs being crossed light up as it passes', (tester) async { + // The tint is derived from the pill's distance, not from `selected`. With a + // boolean, Home turned red on the tap while the pill was still two tabs + // away — the destination lighting up before the thing travelling to it + // arrives is most of what made the old version feel disconnected. + final selected = ValueNotifier(3); + addTearDown(selected.dispose); + await tester.pumpWidget(bar(selected: selected, initial: 3)); + await tester.pumpAndSettle(); + + // Only Account is lit at rest. + expect(_lit(tester), [0.0, 0.0, 0.0, 1.0]); + + selected.value = 0; + await tester.pump(); + + var sawMiddleGlow = false; + for (var elapsed = 0; elapsed < 400; elapsed += 20) { + await tester.pump(const Duration(milliseconds: 20)); + 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'); + + await tester.pumpAndSettle(); + expect(_lit(tester), [1.0, 0.0, 0.0, 0.0]); + }); +} + +/// The `lit` value each tab is rendering, read back off the widget tree. +List _lit(WidgetTester tester) => tester + .widgetList<_Tab>(find.byType(_Tab)) + .map((w) => double.parse(w.lit.toStringAsFixed(3))) + .toList(); + +class _PillBar extends StatefulWidget { + final ValueNotifier selected; + final int initial; + final Key pillKey; + final int tabs; + final Duration duration; + + const _PillBar({ + super.key, + required this.selected, + required this.initial, + required this.pillKey, + required this.tabs, + required this.duration, + }); + + @override + State<_PillBar> createState() => _PillBarState(); +} + +class _PillBarState extends State<_PillBar> with SingleTickerProviderStateMixin { + late final AnimationController _anim; + late final Animation _curve; + late Tween _tween; + + double get _pos => _tween.evaluate(_curve); + + @override + void initState() { + super.initState(); + _anim = AnimationController( + vsync: this, + duration: widget.duration, + value: 1.0, + ); + _curve = CurvedAnimation(parent: _anim, curve: Curves.easeInOutCubic); + _tween = Tween( + begin: widget.initial.toDouble(), + end: widget.initial.toDouble(), + ); + widget.selected.addListener(_onSelect); + } + + void _onSelect() { + _tween = Tween(begin: _pos, end: widget.selected.value.toDouble()); + _anim.forward(from: 0); + } + + @override + void dispose() { + widget.selected.removeListener(_onSelect); + _anim.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _anim, + builder: (context, _) { + final pos = _pos; + return LayoutBuilder( + builder: (context, box) { + final slot = box.maxWidth / widget.tabs; + return Stack( + children: [ + PositionedDirectional( + start: slot * pos, + top: 0, + bottom: 0, + width: slot, + child: Container(key: widget.pillKey, color: Colors.red), + ), + Row( + children: List.generate(widget.tabs, (i) { + final t = (1.0 - (pos - i).abs()).clamp(0.0, 1.0); + return Expanded(child: _Tab(lit: t * t * t)); + }), + ), + ], + ); + }, + ); + }, + ); + } +} + +class _Tab extends StatelessWidget { + final double lit; + const _Tab({required this.lit}); + + @override + Widget build(BuildContext context) => const SizedBox.expand(); +} diff --git a/test/card_cta_height_test.dart b/test/card_cta_height_test.dart new file mode 100644 index 0000000..da6a5f8 --- /dev/null +++ b/test/card_cta_height_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:miler/views/helpers/constants/app_theme.dart'; + +/// The stop card's CTA is an `ElevatedButton.icon` inside a fixed-height +/// `SizedBox`, and the app theme styles `ElevatedButton` for full-width FORM +/// buttons: `padding: vertical 18` and a 56dp `minimumSize`. +/// +/// Those two defaults are a trap for any button in a list row. `styleFrom` +/// replaces only the keys it is handed, so a card that overrides colour, shape +/// and elevation still inherits the form metrics. The padding then eats the +/// height from the inside: at 48dp the content band is 48 − 36 = **12px**, and +/// the label and icon are squeezed into it — glyphs cut through the middle, +/// icon painted at 12px instead of its declared 19px. +/// +/// Two things make it worth a test. It is **silent** — no overflow exception, +/// nothing in the console, the button just renders half a word — and it is +/// invisible at 56dp, where the band is exactly 20px and one line happens to +/// fit. So the bug is dormant in any card using the theme's metrics and wakes +/// up the moment someone changes the height. +/// +/// `PickupCard` is private to the `pickups.dart` library and cannot be +/// imported, so — as in `card_stripe_layout_test.dart` — these tests pin the +/// idiom rather than the widget. +void main() { + const iconSize = 19.0; + const labelFontSize = 14.5; + const label = 'Start Combined Stop'; + + /// The card's CTA, at the height the card gives it. + Widget cta({required bool withOverrides, double height = 48}) { + return MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: Center( + child: SizedBox( + width: 268, // CTA share of a 390pt-wide card, beside the ⋯ button + child: SizedBox( + height: height, + child: ElevatedButton.icon( + onPressed: () {}, + icon: const Icon(Icons.arrow_forward_rounded, size: iconSize), + label: const Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w800, fontSize: labelFontSize), + ), + style: withOverrides + ? ElevatedButton.styleFrom( + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ) + : ElevatedButton.styleFrom(elevation: 0), + ), + ), + ), + ), + ), + ); + } + + testWidgets('the themed form metrics squeeze a 48dp CTA', (tester) async { + await tester.pumpWidget(cta(withOverrides: false)); + + // Nothing is thrown — this is the failure mode that ships. + expect(tester.takeException(), isNull); + + // 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'); + }); + + 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 text = tester.getRect(find.text(label)); + + // The SizedBox is the single source of truth for the height. + expect(button.height, 48); + + // Nothing is squeezed: the icon gets the size it asked for, and the label + // gets at least a full line box. + expect(icon.height, iconSize); + expect(text.height, greaterThanOrEqualTo(labelFontSize)); + + // And both sit fully inside the button rather than being clipped by it. + for (final part in [icon, text]) { + expect(part.top, greaterThanOrEqualTo(button.top)); + expect(part.bottom, lessThanOrEqualTo(button.bottom)); + expect(part.left, greaterThanOrEqualTo(button.left)); + expect(part.right, lessThanOrEqualTo(button.right)); + } + }); + + // ── The same trap, on OutlinedButton ── + // + // Not a hypothetical: an audit found `sheet.dart` giving a 48dp box to an + // `OutlinedButton.icon` whose style overrode only the side and the shape. The + // theme's `outlinedButtonTheme` carries `vertical: 16`, leaving a 16px content + // band, and the row's 18sp icon was measured painting at **16px**. Silently — + // this produces no overflow error, which is why it survived. + group('OutlinedButton inherits the same form metrics', () { + Widget outlined({required bool withOverrides, double height = 48}) { + return MaterialApp( + theme: AppTheme.light, + home: Scaffold( + body: Center( + child: SizedBox( + width: 220, + 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)), + style: withOverrides + ? OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 14), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ) + : null, + ), + ), + ), + ), + ); + } + + 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'); + }); + + testWidgets('and does not with them', (tester) async { + await tester.pumpWidget(outlined(withOverrides: true)); + expect(tester.takeException(), isNull); + + final button = tester.getRect(find.byType(OutlinedButton)); + final icon = tester.getRect(find.byType(Icon)); + expect(button.height, 48); + expect(icon.height, 18, reason: 'the icon gets the size it asked for'); + expect(icon.top, greaterThanOrEqualTo(button.top - 0.01)); + expect(icon.bottom, lessThanOrEqualTo(button.bottom + 0.01)); + }); + }); +} diff --git a/test/card_density_test.dart b/test/card_density_test.dart new file mode 100644 index 0000000..48ad7f9 --- /dev/null +++ b/test/card_density_test.dart @@ -0,0 +1,990 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.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/trip_card.dart'; +import 'package:miler/views/Dashboard/home/trip_tabs.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; + +/// Density is the whole point of these two redesigns, so it is asserted rather +/// than claimed. A phone screen is roughly 844pt tall; how much of it the +/// permanent furniture eats decides how many stops the rider can see. +void main() { + Future pump(WidgetTester tester, Widget child) async { + tester.view.physicalSize = const Size(1170, 2532); + 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(); + } + + Map stop(String id, {double cash = 0}) => { + 'orderid': id, + 'type': 'pickup', + 'pickupcustomer': 'Sri Balaji Stores', + 'pickupaddress': '19, Thadagam Road, Venkitapuram, Coimbatore', + 'pickupqty': '3', + 'Quantity': '3', + if (cash > 0) 'collectionamt': '$cash', + 'expected_pickup_time': DateTime.now().toIso8601String(), + }; + + // ── The trip brief is collapsed by default, and that is the whole point ── + // + // The four surfaces this replaced — a shift strip, a "CURRENT SHIFT" card, a + // 66dp ring and three stat cards — measured ~351dp at this design size, on a + // scrollable region of roughly 650. Over half the screen was spent before the + // 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. + 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)])), + ); + return tester.getSize(find.byType(TripBriefStrip)).height; + } + + testWidgets('stays compact when collapsed', (tester) async { + final h = await briefHeight(tester); + expect( + h, + lessThan(90), + reason: + 'measured $h — the brief is the permanent furniture above the ' + 'route, so every point of it is charged at every scroll position', + ); + }); + + testWidgets('cash does not make it taller', (tester) async { + // The COD figure rides as a chip on the same row, not a fourth card and + // not a second line. + final without = await briefHeight(tester); + final with_ = await briefHeight(tester, cash: 1250); + expect( + with_, + without, + reason: 'adding a COD figure must not grow the collapsed strip', + ); + }); + + testWidgets('holds the journey figures back until it is opened', ( + tester, + ) async { + await pump( + tester, + 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 = [ + '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', + ); + } + expect( + find.text('CURRENT SHIFT'), + 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); + }); + + 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 + // in an unbounded scroll extent — see `card_stripe_layout_test.dart`. It + // cost a build once. The replacement has no stretch row at all, and this + // holds that it stays that way. + await pump( + tester, + 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); + }); + }); + + group('Trip tabs', () { + testWidgets('fit in one compact row', (tester) async { + await pump( + tester, + TripTabs( + trips: [ + Trip.fromStops(id: 't1', stops: [stop('A')]), + Trip.fromStops(id: 't2', stops: [stop('B')]), + ], + selectedIndex: 0, + onSelect: (_) {}, + ), + ); + + final h = tester.getSize(find.byType(TripTabs)).height; + // ── This bound was raised deliberately, and it cost something ── + // + // The strip went from one line (~41pt) to a segmented control carrying a + // label, a status line and a running-indicator (~70pt). That is ~30pt of + // *permanent* height, and it is worse than it sounds because the bar is + // now a pinned sliver: it is on screen at every scroll position, not just + // at the top. + // + // What it buys is the separation of "the trip I am looking at" from "the + // trip that is running", which the one-line version could not express at + // all, plus per-trip progress in words rather than an ambiguous bar. That + // is a real trade, taken on purpose — but the guard stays, because the + // failure mode here is a strip that keeps growing a line at a time until + // it crowds out the route it is supposed to be navigating. + expect( + h, + lessThan(80), + reason: + 'the tab strip is pinned furniture — it is on screen at ' + 'every scroll position, so its height is charged continuously', + ); + expect(tester.takeException(), isNull); + }); + + testWidgets('still show three slots before the hub assigns them', ( + tester, + ) async { + await pump( + tester, + TripTabs( + trips: [ + Trip.fromStops(id: 't1', stops: [stop('A')]), + ], + selectedIndex: 0, + onSelect: (_) {}, + ), + ); + expect(find.text('Trip 1'), findsOneWidget); + expect(find.text('Trip 2'), findsOneWidget); + expect(find.text('Trip 3'), findsOneWidget); + }); + }); + + // ── A stop row carries no controls at all ── + // + // Every row used to have its own Accept and Reject, and for a while an + // accepted row kept a live Reject beside its own green confirmation — so a + // stop could be accepted and then rejected a second later, with the two wire + // calls racing. Both buttons are gone from the row entirely now: ticking + // chooses, and the floating bar is the only thing that commits. + group('decision row', () { + Future pumpStop( + WidgetTester tester, { + Set accepted = const {}, + Set rejected = const {}, + }) async { + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: [stop('A')]), + acceptedIds: accepted, + rejectedIds: rejected, + onToggleSelect: (_) {}, + onAcceptSelected: (_) async {}, + ), + ); + } + + testWidgets('an undecided stop offers a tick, not buttons', (tester) async { + await pumpStop(tester); + + for (final label in ['Reject', 'Accept', 'Accept instead']) { + expect( + find.widgetWithText(MilerButton, label), + findsNothing, + 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); + }); + + testWidgets('an accepted stop leaves the screen entirely', (tester) async { + await pumpStop(tester, accepted: {'A'}); + + // Home lists what still needs deciding here. Once a stop is taken it is + // 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.text('Every stop on this trip is taken — work them from Bookings'), + findsOneWidget, + ); + }); + + testWidgets('a skipped stop leaves the screen, like a finished one', ( + tester, + ) async { + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: [ + {...stop('A'), 'orderstatus': 'skipped'}, + ]), + onToggleSelect: (_) {}, + onAcceptSelected: (_) async {}, + ), + ); + + // A skip is a record of something that happened at a door, not an offer + // waiting on a decision — so it is Activity's row now, with the Resume + // control on it. Home is only ever "what am I taking?". + 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'), + findsOneWidget, + ); + }); + + testWidgets('a rejected stop can be put back to undecided', (tester) async { + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: [stop('A')]), + rejectedIds: const {'A'}, + onToggleSelect: (_) {}, + onAcceptSelected: (_) async {}, + onUnrejectStop: (_) async {}, + ), + ); + + expect(find.widgetWithText(MilerButton, 'Undo reject'), findsOneWidget); + // Undoing returns it to the queue; it does not accept it outright. + expect(find.widgetWithText(MilerButton, 'Accept'), findsNothing); + }); + + testWidgets('a rejected stop is stated by its chip, not a button', ( + tester, + ) async { + await pumpStop(tester, rejected: {'A'}); + + expect(find.text('Rejected'), findsOneWidget); + expect( + find.widgetWithText(MilerButton, 'Rejected'), + findsNothing, + reason: 'a button that cannot be pressed is not a status', + ); + }); + }); + + // ── One accept in the whole screen ── + // + // The footer used to carry "Accept all N stops", a second bulk action over a + // different set of stops from the floating bar's — and the difference between + // the two sets was exactly the stops the rider had just decided against. + group('bulk action', () { + List> threeStops() => [ + stop('A'), + stop('B'), + stop('C'), + ]; + + testWidgets('the footer has no accept button of its own', (tester) async { + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: threeStops()), + onAcceptSelected: (_) async {}, + onToggleSelect: (_) {}, + ), + ); + expect(find.textContaining('Accept all'), findsNothing); + 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, + ); + }); + + testWidgets('and says where the action is once stops are ticked', ( + tester, + ) async { + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: threeStops()), + selectedIds: const {'A', 'B'}, + onAcceptSelected: (_) async {}, + onToggleSelect: (_) {}, + ), + ); + expect( + find.text('Accept the stops you picked from the bar below'), + findsOneWidget, + ); + }); + }); + + group('Trip brief contents', () { + testWidgets('the shift is labelled, and the trip slot is not repeated', ( + tester, + ) async { + // Two bugs, closed one after the other. + // + // First: the old card captioned `trip.slotLabel` — the TRIP's window — as + // "CURRENT SHIFT", while the rider's actual shift sat in a separate strip + // directly above it. Two ranges, stacked, one wearing the other's name. + // The fix was a labelled row each. + // + // Then the slot row turned out to be printing a range the rider could + // already see: the collapsed face falls back to `slotLabel`, and the trip + // TAB above the strip is labelled with it too. So the slot keeps its + // places and the brief keeps only the window that is genuinely its own. + final trip = Trip.fromStops(id: 't1', stops: [stop('A')]); + final brief = RouteBrief.from( + orderIds: const [], + ordersMap: const {}, + shiftStart: '09:00:00', + shiftEnd: '18:00:00', + ); + await pump(tester, TripCard(trip: trip, brief: brief)); + + await tester.tap(find.byType(TripBriefStrip)); + await tester.pumpAndSettle(); + + expect(find.text('Shift'), findsOneWidget); + expect( + find.text('Trip slot'), + findsNothing, + reason: 'the slot is on the face and on the tab; a third copy is noise', + ); + expect( + find.text(brief.shiftWindowLabel), + findsOneWidget, + reason: "the shift row states the rider's window, not the trip's", + ); + }); + + testWidgets('the brief holds no actions, only figures', (tester) async { + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: [stop('A')]), + onViewRoute: () {}, + ), + ); + await tester.tap(find.byType(TripBriefStrip)); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(TripBriefStrip), + matching: find.text('MAP'), + ), + findsNothing, + reason: + 'the map is an action; it belongs on the route, not in a list of ' + 'read-only figures', + ); + }); + }); + + // ── The map opens from where the route begins ── + // + // It used to be buried at the foot of the trip brief: an action among nine + // read-only figures, two taps deep behind a strip the rider has no other + // reason to open. It now sits on the START · HUB row, which is both where the + // route starts and where his eye already is when he starts it. + group('MAP on the hub row', () { + testWidgets('is reachable without opening anything', (tester) async { + var opened = false; + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: [stop('A')]), + onViewRoute: () => opened = true, + ), + ); + + expect(find.text('MAP'), findsOneWidget); + expect( + 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.pumpAndSettle(); + expect(opened, isTrue); + }); + + // ── It has to fit in the row it sits on ── + // + // Aligning the hub node with its label once boxed the whole row to the + // node's 22dp height, which cropped this control — a tap target with its + // own padding and a chevron — through the middle. The band is sized for the + // tallest thing on the row now, and this is what says so. + testWidgets('is drawn whole, not cropped by the row it sits in', ( + tester, + ) async { + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: [stop('A')]), + onViewRoute: () {}, + ), + ); + + 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)); + + // 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]) { + 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)); + }); + + testWidgets('and stays hidden when there is nothing to open', ( + tester, + ) async { + await pump( + tester, + TripCard(trip: Trip.fromStops(id: 't1', stops: [stop('A')])), + ); + expect( + find.text('MAP'), + findsNothing, + reason: 'the action is hidden until a callback is supplied', + ); + }); + + testWidgets('only on START · HUB, never on RETURN · HUB', (tester) async { + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: [stop('A')]), + onViewRoute: () {}, + ), + ); + expect( + find.text('MAP'), + findsOneWidget, + reason: 'one map action per trip, on the row the route starts from', + ); + expect( + tester.getRect(find.text('MAP')).top, + lessThan(tester.getRect(find.text('RETURN · HUB')).top), + ); + }); + }); + + group('Trip tab states', () { + Future pumpTabs(WidgetTester tester, {required int selected}) async { + await pump( + tester, + TripTabs( + trips: [ + Trip.fromStops(id: 't1', stops: [stop('A')]), + Trip.fromStops(id: 't2', stops: [stop('B')]), + ], + selectedIndex: selected, + onSelect: (_) {}, + ), + ); + } + + Color colourOf(WidgetTester tester, String label) => + tester.widget(find.text(label)).style!.color!; + + testWidgets('the selected tab takes the brand accent', (tester) async { + // The bug: the pill moved but the label stayed slate, so standing on + // Trip 2 looked no different from Trip 2 sitting idle. + await pumpTabs(tester, selected: 1); + expect( + colourOf(tester, 'Trip 2'), + ColorConstants.primary, + reason: 'the tab the rider is on must read as picked', + ); + expect(colourOf(tester, 'Trip 1'), isNot(ColorConstants.primary)); + + await pumpTabs(tester, selected: 0); + expect(colourOf(tester, 'Trip 1'), ColorConstants.primary); + expect( + colourOf(tester, 'Trip 3'), + isNot(ColorConstants.primary), + reason: 'an unassigned slot stays quiet', + ); + }); + + testWidgets('the strip stays compact after the state work', (tester) async { + await pumpTabs(tester, selected: 0); + final h = tester.getSize(find.byType(TripTabs)).height; + expect( + h, + lessThan(66), + reason: + 'measured $h — the segmented strip is pinned furniture, so ' + 'every point of it is charged at every scroll position', + ); + }); + }); + + group('Trip tab selection marker', () { + Future pumpTabs(WidgetTester tester, {required int selected}) async { + await pump( + tester, + TripTabs( + trips: [ + Trip.fromStops(id: 't1', stops: [stop('A')]), + Trip.fromStops(id: 't2', stops: [stop('B')]), + ], + selectedIndex: selected, + onSelect: (_) {}, + ), + ); + } + + /// Same tree, but leaves the pill mid-flight so it can be sampled. + Future pumpTabsNoSettle( + WidgetTester tester, { + required int selected, + }) async { + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripTabs( + trips: [ + Trip.fromStops(id: 't1', stops: [stop('A')]), + Trip.fromStops(id: 't2', stops: [stop('B')]), + ], + selectedIndex: selected, + onSelect: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pump(); + } + + /// The travelling selection marker. + /// + /// Found by key, not by colour. It moved out of the segments and into the + /// pill when it was made to travel, and colour cannot identify it any more: + /// crossing an unassigned trip it is deliberately grey rather than the brand + /// red, so a colour finder loses it exactly during the journey these tests + /// exist to measure. + Rect underline(WidgetTester tester) { + final marks = find.byKey(TripTabs.markerKey); + expect( + marks, + findsOneWidget, + reason: 'exactly one tab may be marked at a time', + ); + return tester.getRect(marks); + } + + // The marker followed a separate "this trip is running" signal for a while, + // which left Trip 1 permanently underlined while the rider stood on Trip 2 — + // so a tap appeared to do nothing. The marker belongs to selection. + testWidgets('the underline sits under the selected tab', (tester) async { + await pumpTabs(tester, selected: 0); + final first = underline(tester); + final trip1 = tester.getRect(find.text('Trip 1')); + expect( + first.center.dx, + closeTo(trip1.center.dx, 2), + reason: 'the marker should be under Trip 1', + ); + }); + + testWidgets('and travels when a different tab is picked', (tester) async { + await pumpTabs(tester, selected: 0); + final before = underline(tester).center.dx; + + await pumpTabs(tester, selected: 1); + await tester.pumpAndSettle(); + final after = underline(tester).center.dx; + final trip2 = tester.getRect(find.text('Trip 2')); + + expect( + after, + greaterThan(before), + reason: + 'the marker moved from $before to $after — it must follow ' + 'the selection rightward to Trip 2', + ); + expect(after, closeTo(trip2.center.dx, 2)); + }); + + testWidgets('Trip 3 → Trip 1 slides across Trip 2 rather than jumping', ( + tester, + ) async { + // The pill used to belong to each segment: one faded out on the right + // while a *different* one faded in on the left, so nothing ever occupied + // the space between and there was no object to cross Trip 2 with. + await pumpTabs(tester, selected: 2); + await tester.pumpAndSettle(); + final trip2 = tester.getRect(find.text('Trip 2')); + final start = underline(tester).center.dx; + expect( + start, + greaterThan(trip2.center.dx), + reason: 'sanity: the marker starts to the right of Trip 2', + ); + + // `pumpTabs` pumps and settles; drive the frames by hand instead so the + // journey can be sampled. + await pumpTabsNoSettle(tester, selected: 0); + + final samples = []; + for (var elapsed = 0; elapsed < 420; elapsed += 20) { + await tester.pump(const Duration(milliseconds: 20)); + samples.add(underline(tester).center.dx); + } + + final half = trip2.width / 2; + expect( + samples.any((x) => (x - trip2.center.dx).abs() < half), + isTrue, + reason: 'the marker never passed over Trip 2; samples: $samples', + ); + for (var i = 1; i < samples.length; i++) { + expect( + samples[i], + lessThanOrEqualTo(samples[i - 1] + 0.5), + reason: 'the marker moved backwards at sample $i: $samples', + ); + } + + await tester.pumpAndSettle(); + expect( + underline(tester).center.dx, + closeTo(tester.getRect(find.text('Trip 1')).center.dx, 2), + ); + }); + + testWidgets('the pill is the brand tint, not a white card', (tester) async { + // It was a white pill with a drop shadow, which made this the one + // selected-state in the app that was not the brand colour — the two + // controls a rider uses most looked like they came from different apps. + await pumpTabs(tester, selected: 0); + + final fills = tester + .widgetList(find.byType(Container)) + .map((c) => c.decoration) + .whereType() + .toList(); + + final glass = ColorConstants.primary.withValues(alpha: 0.12); + expect( + fills.map((d) => d.color), + contains(glass), + reason: + 'the selection pill should be the light glass red the bottom ' + 'nav bar uses', + ); + expect( + fills.where( + (d) => d.color == Colors.white && (d.boxShadow?.isNotEmpty ?? false), + ), + isEmpty, + reason: 'no raised white pill should remain', + ); + }); + }); + + // ── Home has exactly two horizontal insets ── + // + // Bookings cards sit at 16 (`PickupCard` carries `margin: horizontal 16.w` + // and its list adds no horizontal padding). Home is wider on purpose: it is a + // denser screen — shift card, three stat cards, a pinned tab strip and a rail + // above the route — and at 16 the whole column read as pressed against the + // border. + // + // Pinned because it is the kind of value that drifts a couple of points at a + // time. What matters is that every element on the screen agrees on it: the + // cards, the tab strip and the accept bar were at 16, 20 and 18 respectively + // at one point, which is what a gutter looks like when nobody is checking. + // ── Select-all leads the list it selects ── + // + // It used to sit under the last hub row, a full screen of scrolling below the + // 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')]; + + Future pumpTrip(WidgetTester tester, {Set picked = const {}}) { + return pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: threeStops()), + selectedIds: picked, + onToggleSelect: (_) {}, + onSelectAll: (_) {}, + onAcceptSelected: (_) async {}, + ), + ); + } + + testWidgets('sits above the route, not below it', (tester) async { + await pumpTrip(tester); + + final selectAll = find.text('Select all 3 stops'); + expect(selectAll, findsOneWidget); + expect( + tester.getRect(selectAll).top, + lessThan(tester.getRect(find.text('START · HUB')).top), + reason: 'it heads the list; the route starts under it', + ); + }); + + 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); + }); + + 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); + }); + + testWidgets('stays away when there is only one stop to pick', + (tester) async { + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: [stop('A')]), + onToggleSelect: (_) {}, + onSelectAll: (_) {}, + onAcceptSelected: (_) async {}, + ), + ); + expect(find.textContaining('Select all'), findsNothing); + }); + }); + + // ── Finished work leaves the decision screen ── + // + // A completed stop used to stay in the list, greyed out with a "Completed" + // 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. + 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'), + ]); + + await pump( + tester, + TripCard( + trip: trip, + acceptedIds: const {'A'}, + onToggleSelect: (_) {}, + onAcceptSelected: (_) async {}, + ), + ); + + // 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); + }); + + testWidgets('the numbering does not renumber as stops finish', ( + tester, + ) async { + // Stop 1 is done, so the list starts at 2 — it does not slide up and + // call the second stop "1", which would rename a stop mid-shift. + await pump( + tester, + TripCard( + 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); + }); + }); + + 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"'); + } + + testWidgets('the summary cards and the stop rows each keep their inset', ( + tester, + ) async { + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 340)]), + ), + ); + + 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( + of: find.byType(TripBriefStrip), + matching: find.byWidgetPredicate( + (w) => + w is Container && + w.decoration is BoxDecoration && + (w.decoration as BoxDecoration).borderRadius != null, + ), + ); + final right = screen - tester.getRect(surface.first).right; + expect( + right, + closeTo(homeGutter, 0.5), + reason: + "the brief ends $right 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), + reason: + 'the stop card ends $stopRight from the edge; it should be ' + 'inset to $stopRowInset rather than share the summary gutter', + ); + expect( + stopRight, + greaterThan(homeGutter), + reason: 'the point of the inset is that it clears the border more', + ); + }); + + testWidgets('the rail is inset as far as the card edge', (tester) async { + await pump( + tester, + TripCard( + trip: Trip.fromStops(id: 't1', stops: [stop('A')]), + ), + ); + + // 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 + expect( + railLeft - node.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', + ); + }); + }); +} diff --git a/test/card_stripe_layout_test.dart b/test/card_stripe_layout_test.dart new file mode 100644 index 0000000..0daf505 --- /dev/null +++ b/test/card_stripe_layout_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The accent stripe on the Bookings stop card is a `Container` with a width +/// and no height, laid out by `Row(crossAxisAlignment: stretch)`. That idiom is +/// a trap inside a `ListView`: stretch sizes children to the Row's own +/// cross-axis extent, which in a scrollable is UNBOUNDED, so the stripe is +/// asked to be infinitely tall and the entire card fails to lay out. +/// +/// It fails silently in the worst possible way — the progress rail above the +/// list is a separate widget reading the same data, so it kept showing the +/// right stop count while every card rendered as nothing. That looked exactly +/// like "the bookings data is broken", and three rounds of data fixes chased +/// it before the layout was suspected. +/// +/// PickupCard itself is private to the `pickups.dart` library and cannot be +/// imported, so these tests pin the idiom rather than the widget. +void main() { + Widget inList(Widget child) => MaterialApp( + home: Scaffold( + body: ListView(children: [child]), + ), + ); + + Widget stripeRow({required bool withIntrinsicHeight}) { + final row = Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container(width: 4, color: const Color(0xFF960019)), + const Expanded( + child: Padding( + padding: EdgeInsets.all(12), + child: Text('Sri Balaji Stores\n19, Thadagam Road'), + ), + ), + ], + ); + return Container( + margin: const EdgeInsets.all(8), + child: ClipRRect( + borderRadius: BorderRadius.circular(15), + child: withIntrinsicHeight ? IntrinsicHeight(child: row) : row, + ), + ); + } + + 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'); + }); + + testWidgets('IntrinsicHeight gives stretch something finite to stretch to', + (tester) async { + await tester.pumpWidget(inList(stripeRow(withIntrinsicHeight: true))); + expect(tester.takeException(), isNull); + + // And the card has real height — an invisible zero-height card would pass + // an exception check while still showing the rider nothing. + final size = tester.getSize(find.byType(IntrinsicHeight)); + expect(size.height, greaterThan(20)); + + // 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, + ); + expect(stripe.height, size.height); + expect(stripe.width, 4); + }); +} diff --git a/test/collect_payment_test.dart b/test/collect_payment_test.dart new file mode 100644 index 0000000..867ae4a --- /dev/null +++ b/test/collect_payment_test.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +import 'package:miler/views/Dashboard/pickups/collect_payment.dart'; + +/// The payment screen is the only place in the app where money changes hands, +/// and it is read by two people at once — the rider holding the phone and the +/// customer being asked to pay. So the things worth testing are the things +/// either of them would dispute afterwards: +/// +/// • the amount comes from the booking and nowhere else; +/// • the page says which consignment is being paid for; +/// • the QR is on the page, carrying that amount, not in a dialog; +/// • a short payment cannot be recorded without a figure and a reason. +void main() { + Future pump( + WidgetTester tester, + Map stop, { + Map verification = const {}, + String? initialMethod, + double textScale = 1.0, + }) async { + tester.view.physicalSize = const Size(390 * 3, 844 * 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( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: CollectPaymentPage( + pickup: stop, + verificationData: verification, + initialMethod: initialMethod, + ), + ), + ), + ); + // Not pumpAndSettle: the slide-to-confirm control animates its hint + // forever, so a settle never comes. Two frames is enough for this page, + // which has no entrance animation of its own. + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + } + + Map stopOf({double due = 450, double pickupAmt = 0}) => + { + 'orderid': 'DM-99120', + 'type': 'pickup', + 'pickupqty': '3', + 'Quantity': '3', + 'pickupcustomer': 'Priya Sharma', + 'tenantname': 'Chettinad Textiles', + 'pickupaddress': '12/3B, Second Cross Street, Ram Nagar, Coimbatore', + 'collectionamt': '$due', + if (pickupAmt > 0) 'pickupamt': '$pickupAmt', + }; + + 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); + expect(find.text('from Priya Sharma'), findsOneWidget); + }); + + testWidgets('the page says what the money is for', (tester) async { + await pump( + tester, + stopOf(), + verification: { + 'weight': '4.5', + 'pickup': {'collected': 3}, + }, + ); + + // The consignment, the shipper it belongs to, and what was actually taken — + // this is the half of the screen a customer paying at his own door needs + // before he will trust the number above it. + expect(find.text('What this pays for'), findsOneWidget); + expect(find.text('DM-99120'), findsOneWidget); + expect(find.text('Chettinad Textiles'), findsOneWidget); + expect(find.text('3 collected'), findsOneWidget); + expect(find.text('4.5 kg'), findsOneWidget); + }); + + testWidgets('cash is the default, and it shows no QR', (tester) async { + await pump(tester, stopOf()); + + expect(find.byType(QrImageView), findsNothing); + expect( + find.textContaining('Slide — took ₹450 by Cash'), + findsOneWidget, + ); + }); + + 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); + await tester.tap(find.text('UPI / QR')); + await tester.pump(const Duration(milliseconds: 300)); + + // On the page, next to the consignment it is settling — no dialog, and + // nothing hidden behind a further confirm. + expect(find.byType(QrImageView), findsOneWidget); + expect(find.text('₹450 to miler@upi'), findsOneWidget); + }); + + group('the UPI intent', () { + test('carries the amount, the payee and the consignment', () { + final uri = buildUpiUri( + payeeVpa: 'shop@okhdfcbank', + payeeName: 'Chettinad Textiles', + orderId: 'DM-99120', + amount: 450, + ); + + // The amount is baked in, so the customer cannot pay the wrong figure by + // mistyping it; the note makes the payment identifiable in his own + // statement weeks later. + expect(uri, contains('pa=shop%40okhdfcbank')); + expect(uri, contains('am=450.00')); + expect(uri, contains('cu=INR')); + expect(uri, contains(Uri.encodeComponent('Miler pickup DM-99120'))); + }); + + test('escapes a payee name that would otherwise break the intent', () { + final uri = buildUpiUri( + payeeVpa: 'shop@upi', + payeeName: 'Ram & Co · Traders', + orderId: '', + amount: 99.5, + ); + + expect(uri, isNot(contains('&pn=Ram & Co'))); + expect(uri, contains('am=99.50')); + expect(uri, contains(Uri.encodeComponent('Miler pickup'))); + }); + }); + + testWidgets('the caller can open straight onto UPI', (tester) async { + // The Bookings sheet already asked which method; asking again on arrival + // would be the extra tap this page exists to remove. + await pump(tester, stopOf(), initialMethod: 'upi (₹450)'); + expect(find.byType(QrImageView), findsOneWidget); + }); + + testWidgets('a short payment needs a figure and a reason', (tester) async { + await pump(tester, stopOf(due: 450)); + + await tester.ensureVisible(find.text('Paid less')); + await tester.pump(); + await tester.tap(find.text('Paid less')); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Enter how much you actually took'), findsOneWidget); + + await tester.enterText(find.byType(TextField).first, '400'); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Say why the customer paid less'), findsOneWidget); + + await tester.enterText(find.byType(TextField).last, 'No change'); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('₹50 still owed on this consignment'), findsOneWidget); + }); + + 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)); + + expect(find.text('Pickup charge'), findsOneWidget); + expect(find.text('₹60'), findsOneWidget); + expect(find.text('Due now'), findsOneWidget); + expect(find.text('₹510'), findsNothing); + }); + + testWidgets('a prepaid stop is not a payment screen at all', (tester) async { + await pump(tester, { + 'orderid': 'DM-1', + 'pickupcustomer': 'Priya Sharma', + }); + + expect(find.text('Nothing to collect here'), findsOneWidget); + expect(find.byType(QrImageView), findsNothing); + }); + + for (final scale in const [1.0, 1.5, 2.0]) { + testWidgets('lays out at ${scale}x text scale', (tester) async { + await pump(tester, stopOf(due: 1250, pickupAmt: 60), textScale: scale); + expect(tester.takeException(), isNull); + + await tester.ensureVisible(find.text('UPI / QR')); + await tester.pump(); + await tester.tap(find.text('UPI / QR')); + await tester.pump(const Duration(milliseconds: 300)); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/completed_store_test.dart b/test/completed_store_test.dart new file mode 100644 index 0000000..cc71c74 --- /dev/null +++ b/test/completed_store_test.dart @@ -0,0 +1,143 @@ +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'; + +/// Finishing a stop is optimistic — the rider is moved on whether or not the +/// status call lands, and the queue endpoints will not report the stop as +/// finished for a poll or two (never, on demo data). This store is the only +/// record in between, and the Activity tab is empty without it. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Map booking(String id) => { + 'orderid': id, + 'pickupcustomer': 'Sri Balaji Stores', + 'pickupaddress': '19, Thadagam Road, Coimbatore', + }; + + String today() { + final t = DateTime.now(); + return '${t.year}-${t.month.toString().padLeft(2, '0')}-' + '${t.day.toString().padLeft(2, '0')}'; + } + + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('a finished stop is readable, and reads as finished', () async { + await addCompletedBookings([booking('A')]); + + final done = await getCompletedBookings(); + expect(done, hasLength(1)); + expect(done.first['orderid'], 'A'); + expect( + stopStatusOf(done.first).isFinishedPickup, + isTrue, + reason: 'Activity filters on the same test Bookings drops a stop with', + ); + expect(done.first['completedat'], isNotNull); + }); + + test('a cancelled stop is stored as cancelled, not picked', () async { + await addCompletedBookings([booking('B')], cancelled: true); + + final done = await getCompletedBookings(); + expect(done.first['orderstatus'], 'cancelled'); + expect(stopStatusOf(done.first).isCancelled, isTrue); + }); + + test('newest first', () async { + await addCompletedBookings([booking('A')]); + await Future.delayed(const Duration(milliseconds: 5)); + await addCompletedBookings([booking('B')]); + + final done = await getCompletedBookings(); + expect(done.map((b) => b['orderid']).toList(), ['B', 'A']); + }); + + test('the same stop twice is one row', () async { + await addCompletedBookings([booking('A')]); + await addCompletedBookings([booking('A')]); + expect(await getCompletedBookings(), hasLength(1)); + }); + + // ── The timings ── + // + // Activity's stop detail can only answer "when did I get there" and "how long + // did that door take" if the two stamps survive the stop being closed. They + // live in prefs keys the *next* stop overwrites, so completion is the only + // 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'}; + + 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')]); + + 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', + }); + + await addCompletedBookings([withPickup('A')]); + + 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); + }); + + 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); + }); + }); + + // 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'}, + {...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'))); + }); +} diff --git a/test/contrast_contract_test.dart b/test/contrast_contract_test.dart new file mode 100644 index 0000000..81df176 --- /dev/null +++ b/test/contrast_contract_test.dart @@ -0,0 +1,290 @@ +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'; + +/// ── Contrast is a contract, not a preference ── +/// +/// This app is read on a phone clamped to a handlebar in Indian daylight, by +/// someone wearing a helmet, while a customer waits. Contrast is not an +/// accessibility checkbox here; it is whether the rider can do the job. +/// +/// An audit of the palette found the app's **second most-used colour failing +/// outright**: `secondaryText` was #8E8E93, which is 3.26:1 on white against a +/// 4.5:1 WCAG AA floor for normal text. It carries captions, labels and +/// metadata on 89 call sites across all 33 screens — so on every screen, the +/// supporting text was the part that disappeared in sunlight. `moneyGreen` +/// exists for the same reason: `acceptGreen` is 2.62:1 as text. +/// +/// Those were fixed at the token, so this file guards the tokens. A colour is a +/// number; a number can be asserted, and that is the only way a palette stays +/// legible through future redesigns. +void main() { + /// WCAG 2.1 relative luminance. + double luminance(Color c) { + double channel(double v) => + v <= 0.03928 ? v / 12.92 : math.pow((v + 0.055) / 1.055, 2.4) as double; + return 0.2126 * channel(c.r) + + 0.7152 * channel(c.g) + + 0.0722 * channel(c.b); + } + + /// WCAG 2.1 contrast ratio, 1.0–21.0. + double ratio(Color a, Color b) { + final la = luminance(a), lb = luminance(b); + return (math.max(la, lb) + 0.05) / (math.min(la, lb) + 0.05); + } + + // Every background the app draws text on. + final surfaces = { + 'white': Colors.white, + 'page (surface)': ColorConstants.surface, + 'card fill (cardSurface)': ColorConstants.cardSurface, + 'neutralLight': ColorConstants.neutralLight, + }; + + /// WCAG AA: 4.5:1 for normal text, 3.0:1 for large or bold text. + const aaNormal = 4.5; + const aaLarge = 3.0; + + group('text colours clear AA on every surface they are used on', () { + final bodyText = { + 'slateText': ColorConstants.slateText, + 'secondaryText': ColorConstants.secondaryText, + 'onSurfaceVariant': ColorConstants.onSurfaceVariant, + 'onSurface': ColorConstants.onSurface, + 'moneyGreen': ColorConstants.moneyGreen, + 'errorRed': ColorConstants.errorRed, + }; + + bodyText.forEach((name, colour) { + 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'); + }); + }); + }); + }); + + /// ── Every token is declared, or the test fails ── + /// + /// The old version guarded six colours. That was a false sense of coverage: + /// an audit found **five tokens failing outright** — `warningOrange` 2.20:1, + /// `successGreen` 2.22:1, `warningYellow` 1.73:1, `cardBlue` 2.77:1, + /// `infoBlue` 4.02:1 — plus `disabledText` at 1.68:1 being used as readable + /// caption text on seven screens. Every one of them sat outside the six. + /// + /// So the contract is inverted. This enumerates the whole palette and forces + /// each colour into exactly one bucket: + /// + /// * **text-safe** — asserted ≥4.5:1 on every surface the app draws on. + /// * **fill** — carries a white label; the label is what is asserted. + /// * **structural** — a hairline or a disabled fill, carrying no words: + /// asserted visible against the surface, and asserted *not* readable, so a + /// colour that could be text has to be filed as text. + /// + /// A new token belongs to a bucket or this test fails, which is the only way + /// the next `warningOrange` cannot land. + group('the palette is exhaustively classified', () { + // Colours the app is allowed to render words in. + final textSafe = { + 'slateText': ColorConstants.slateText, + 'secondaryText': ColorConstants.secondaryText, + 'onSurfaceVariant': ColorConstants.onSurfaceVariant, + 'onSurface': ColorConstants.onSurface, + 'moneyGreen': ColorConstants.moneyGreen, + 'acceptGreen': ColorConstants.acceptGreen, + 'errorRed': ColorConstants.errorRed, + 'error': ColorConstants.error, + 'warning': ColorConstants.warning, + 'primary': ColorConstants.primary, + 'rewardAmber': ColorConstants.rewardAmber, + 'tertiary': ColorConstants.tertiary, + 'deliveryAccent': ColorConstants.deliveryAccent, + 'combinedAccent': ColorConstants.combinedAccent, + 'upiPurple': ColorConstants.upiPurple, + 'emphasis': ColorConstants.emphasis, + }; + + // Colours that fill a shape carrying a white label. The label is what is + // asserted — several of these are dark enough to read as text too, which is + // fine; the bucket is about the job, not about failing some other job. + final fillWithWhiteLabel = { + 'pickupChip': ColorConstants.pickupChip, + 'deliveryChip': ColorConstants.deliveryChip, + 'combinedChip': ColorConstants.combinedChip, + 'primaryContainer': ColorConstants.primaryContainer, + 'successContainer': ColorConstants.successContainer, + 'tertiaryContainer': ColorConstants.tertiaryContainer, + }; + + // Hairlines, dividers and disabled fills. These carry no words at all; the + // only thing that matters is that they are visible against the surface + // behind them. + final structural = { + 'disabledFill': ColorConstants.disabledFill, + 'borderStrong': ColorConstants.borderStrong, + 'borderSubtle': ColorConstants.borderSubtle, + 'dividerColor': ColorConstants.dividerColor, + 'outlineVariant': ColorConstants.outlineVariant, + }; + + textSafe.forEach((name, colour) { + 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'); + }); + }); + }); + + 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'); + }); + }); + + structural.forEach((name, colour) { + test('structural: $name is visible on white', () { + final r = ratio(colour, Colors.white); + // A 1px hairline is not held to a text floor — iOS separators sit + // around 1.1:1 and are the convention riders already read. What is + // 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'); + }); + 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'); + }); + }); + + test('the five retired tokens have not come back', () { + // Compile-time guard. If someone re-adds `warningOrange`, `infoBlue`, + // `successGreen`, `cardBlue` or `warningYellow`, they have to delete this + // list first — and read why they were removed. + const retired = { + 'warningOrange': 0xFFFF9500, + 'successGreen': 0xFF34C759, + 'warningYellow': 0xFFFFB800, + 'cardBlue': 0xFF0EA5E9, + 'infoBlue': 0xFF007AFF, + }; + 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'); + } + }); + }); + + group('white text on a filled control clears AA', () { + final fills = { + 'primary (brand)': ColorConstants.primary, + 'acceptGreen (Accept button)': ColorConstants.acceptGreen, + 'pickupChip': ColorConstants.pickupChip, + 'deliveryChip': ColorConstants.deliveryChip, + 'combinedChip': ColorConstants.combinedChip, + 'warning (skipped)': ColorConstants.warning, + 'errorRed': ColorConstants.errorRed, + }; + + fills.forEach((name, fill) { + test('white on $name', () { + 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'); + }); + }); + + // The chips carry 10sp text, which is small even when bold, so they are held + // to the stricter floor. This is what "darkened" bought. + test('the stop-type chips clear the NORMAL-text floor, not just large', () { + for (final e in { + 'pickupChip': ColorConstants.pickupChip, + 'deliveryChip': ColorConstants.deliveryChip, + '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'); + } + }); + }); + + group('surfaces stay distinguishable', () { + // A borderless card has nothing but its fill to separate it from the page. + // 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 - + 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'); + }); + + test('borderStrong is visible against white and against the card', () { + // Non-text contrast (UI components) has a 3:1 AA floor. + for (final e in { + 'white': Colors.white, + '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'); + } + }); + }); + + group('the brand palette has no tonal leftovers', () { + // The M3 tonal derivation of the red seed produced a brown body text and a + // pink outline, which shipped on 70 call sites and inside `AppTheme` — so + // the auth flow and every text field were brown-and-pink while the + // 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'); + } + + test('onSurfaceVariant is a neutral, not a brown', () { + expectNeutral('onSurfaceVariant', ColorConstants.onSurfaceVariant); + }); + + test('outlineVariant is a neutral, not a pink', () { + expectNeutral('outlineVariant', ColorConstants.outlineVariant); + }); + + test('secondaryText is a neutral', () { + expectNeutral('secondaryText', ColorConstants.secondaryText); + }); + }); +} diff --git a/test/demo_bookings_test.dart b/test/demo_bookings_test.dart new file mode 100644 index 0000000..7bfc770 --- /dev/null +++ b/test/demo_bookings_test.dart @@ -0,0 +1,128 @@ +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/done_handoff_test.dart b/test/done_handoff_test.dart new file mode 100644 index 0000000..3f5bd97 --- /dev/null +++ b/test/done_handoff_test.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/pickups/pickups.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; + +/// ── The handoff screen ranks by type, not by boxes ── +/// +/// Every group on this screen used to be a bordered, filled, rounded rectangle: +/// a green result banner, a shadowed hero card, a bordered list card with a +/// bordered pill inside it and a filled badge inside that. Four containers and a +/// shadow, down a page holding about forty words. +/// +/// The cost was not ugliness, it was ranking. A border says "this is separate +/// from its neighbours"; give that to all four groups and none of them is +/// emphasised, so the rider's eye lands on whichever box is biggest rather than +/// on the one fact he opened the screen for — where he is going next. +/// +/// The redesign carries rank with type size, colour weight and whitespace, and +/// spends its one border on the single change of subject. These tests pin that, +/// because a comment asking for restraint does not survive the next redesign. +void main() { + Map stop( + String id, + String name, + String address, { + required String type, + String kms = '', + }) => { + 'orderid': id, + 'type': type, + 'pickupcustomer': name, + 'pickupaddress': address, + 'kms': kms, + 'orderstatus': 'accepted', + }; + + final route = [ + stop( + '1', + 'Sundar Textiles', + '78, Cross Cut Road, Gandhipuram, Coimbatore', + type: 'combined', + kms: '2.4', + ), + stop( + '2', + 'Green Leaf Grocers', + '29, Kovaipudur Main Road, Coimbatore', + type: 'delivery', + ), + stop( + '3', + 'Ganesh Traders', + '134, Avinashi Road, Hope College, Coimbatore', + type: 'pickup', + ), + ]; + + Future pumpDone(WidgetTester tester, {double topInset = 24}) async { + tester.view.physicalSize = const Size(390 * 3, 844 * 3); + tester.view.devicePixelRatio = 3.0; + tester.view.padding = FakeViewPadding(top: topInset * 3, bottom: 16 * 3); + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + minTextAdapt: true, + builder: (_, _) => MaterialApp( + home: PickupsDone(isDelivery: true, remainingOrders: route), + ), + ), + ); + // Past the 200ms entrance delay and the 800ms controller. + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 900)); + } + + double sizeOf(WidgetTester tester, String text) => + tester.widget(find.text(text)).style!.fontSize!; + + testWidgets('the next stop is the largest thing on the screen', ( + tester, + ) async { + await pumpDone(tester); + + final hero = sizeOf(tester, 'Sundar Textiles'); + + // Against everything else the screen says. + expect(hero, greaterThan(sizeOf(tester, 'NEXT STOP'))); + expect( + hero, + 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'))); + + // And by a margin wide enough to be seen rather than measured: the point is + // that the rider lands on it first, not that it happens to be a point + // bigger than the row beneath it. + expect(hero / sizeOf(tester, 'Green Leaf Grocers'), greaterThan(1.6)); + }); + + testWidgets('finished work is quiet — grey text, green only in the tick', ( + 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); + + // The tick is the whole of the green. + final tick = tester.widget( + find.byIcon(Icons.check_circle_rounded).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, + ); + }); + + testWidgets('nothing in the body is boxed', (tester) async { + await pumpDone(tester); + + // Every decorated shape inside the scrolling content — the type dot, the + // numbered circles, the one hairline. None of them may carry a border or a + // shadow, which is what turns a mark into a card. + final decorations = tester + .widgetList( + find.descendant( + of: find.byType(SingleChildScrollView), + matching: find.byType(Container), + ), + ) + .map((c) => c.decoration) + .whereType(); + + expect(decorations, isNotEmpty, reason: 'sanity: the dots are Containers'); + for (final d in decorations) { + expect(d.border, isNull); + expect(d.boxShadow ?? const [], isEmpty); + } + }); + + testWidgets('the upcoming stops are one list, not a card each', ( + tester, + ) async { + await pumpDone(tester); + + expect(find.text('UP NEXT · 2 STOPS'), findsOneWidget); + + // Both rows present, numbered from their position on the route, each one + // tappable straight into that stop. + for (final row in const ['Green Leaf Grocers', 'Ganesh Traders']) { + expect(find.text(row), findsOneWidget); + expect( + find.ancestor(of: find.text(row), matching: find.byType(InkWell)), + findsOneWidget, + ); + } + expect(find.text('2'), findsOneWidget); + expect(find.text('3'), findsOneWidget); + expect(find.byIcon(Icons.chevron_right_rounded), findsNWidgets(2)); + }); + + testWidgets('the stop type is a label, not a filled badge', (tester) async { + await pumpDone(tester); + + // Sentence case and in the accent, under the address — not an all-caps + // chip floated above the name it is an attribute of. + final label = tester.widget(find.text('Pickup & Delivery')); + expect(label.style!.color, ColorConstants.combinedAccent); + expect(label.style!.fontSize, lessThan(sizeOf(tester, 'Sundar Textiles'))); + expect(find.text('PICKUP & DELIVERY'), findsNothing); + }); + + testWidgets('the page has a top edge of its own', (tester) async { + // This screen carries no app bar — there is nowhere to go back to, so there + // is nothing for one to hold. `SafeArea` alone only clears the status bar, + // which left the first line of the page sitting hard under the clock while + // every screen in the app that *does* have a bar starts its content around + // 90pt down. The handoff read as the one page missing its header rather + // than as the one page that does not need one. + for (final inset in const [24.0, 47.0]) { + await pumpDone(tester, topInset: inset); + final status = tester.getRect(find.text('Delivered · 3 stops left')); + expect( + status.top - inset, + greaterThan(28.0), + reason: 'needs a real margin below the safe area, not just the inset', + ); + } + }); + + testWidgets('the status line belongs to the top, not to the hero', ( + tester, + ) async { + await pumpDone(tester); + + // Asymmetric on purpose. Equal gaps above and below left the completion + // line hanging between the top edge and the destination, belonging to + // neither; tighter above groups it with the top of the page, which is what + // it is — a footnote on the stop the rider just left. + final status = tester.getRect(find.text('Delivered · 3 stops left')); + final eyebrow = tester.getRect(find.text('NEXT STOP')); + final above = status.top - 24.0; + final below = eyebrow.top - status.bottom; + expect(below, greaterThan(above)); + }); + + testWidgets('the CTA is pinned, green and thumb-sized', (tester) async { + await pumpDone(tester); + + expect(find.text('Move to next stop'), findsOneWidget); + + final button = tester.widget(find.byType(MilerButton)); + expect(button.color, ColorConstants.acceptGreen); + expect(button.height, greaterThanOrEqualTo(56.0)); + + // Sitting on the bottom edge, below the scrolling content. + final cta = tester.getRect(find.byType(MilerButton)); + final body = tester.getRect(find.byType(SingleChildScrollView)); + expect(cta.top, greaterThanOrEqualTo(body.bottom)); + expect(cta.bottom, lessThanOrEqualTo(844.0)); + }); +} diff --git a/test/duty_sheet_test.dart b/test/duty_sheet_test.dart new file mode 100644 index 0000000..967f0ce --- /dev/null +++ b/test/duty_sheet_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:slide_to_submit_button/slide_to_submit_button.dart'; + +import 'package:miler/views/Dashboard/home/duty_sheet.dart'; + +/// The duty sheet used to say one thing — "You will stop receiving bookings" — +/// and leave out the two that actually catch a rider out: tracking stops, and +/// the app will refuse to take him off duty while he is still holding work. It +/// also carried a wifi glyph, which says "network problem" on a screen about +/// whether he is working. +void main() { + Future pump( + WidgetTester tester, { + required bool goingOnline, + Future Function()? onConfirm, + }) async { + tester.view.physicalSize = const Size(1170, 2532); + 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: DutySheet( + goingOnline: goingOnline, + onConfirm: onConfirm ?? () async => true, + ), + ), + ), + ), + ), + ); + // The hint arrow loops forever, so there is nothing to settle into. + await tester.pump(const Duration(milliseconds: 300)); + } + + testWidgets('going on duty names the state it is heading for', ( + tester, + ) async { + 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.byIcon(Icons.wifi), findsNothing); + expect(find.byIcon(Icons.wifi_off), findsNothing); + }); + + testWidgets('going off duty lists 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. + expect( + find.text('Any booking you are holding must be finished first'), + findsOneWidget, + ); + }); + + testWidgets('a completed slide runs the toggle', (tester) async { + var calls = 0; + await pump( + tester, + goingOnline: true, + onConfirm: () async { + calls++; + return true; + }, + ); + + // 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.pump(); + await tester.pump(); + + expect(calls, 1); + }); + + testWidgets('a short slide does not', (tester) async { + var calls = 0; + await pump( + tester, + goingOnline: false, + onConfirm: () async { + calls++; + return true; + }, + ); + + 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.pump(); + await tester.pump(); + + expect(calls, 0, reason: 'ending the day takes a deliberate gesture'); + }); +} diff --git a/test/failures/mode_dark_isolatedDiff.png b/test/failures/mode_dark_isolatedDiff.png new file mode 100644 index 0000000..f318d4e Binary files /dev/null and b/test/failures/mode_dark_isolatedDiff.png differ diff --git a/test/failures/mode_dark_maskedDiff.png b/test/failures/mode_dark_maskedDiff.png new file mode 100644 index 0000000..7398d99 Binary files /dev/null and b/test/failures/mode_dark_maskedDiff.png differ diff --git a/test/failures/mode_dark_masterImage.png b/test/failures/mode_dark_masterImage.png new file mode 100644 index 0000000..3d8b2fa Binary files /dev/null and b/test/failures/mode_dark_masterImage.png differ diff --git a/test/failures/mode_dark_testImage.png b/test/failures/mode_dark_testImage.png new file mode 100644 index 0000000..30d8999 Binary files /dev/null and b/test/failures/mode_dark_testImage.png differ diff --git a/test/fixtures/demo_stops.dart b/test/fixtures/demo_stops.dart new file mode 100644 index 0000000..9afb522 --- /dev/null +++ b/test/fixtures/demo_stops.dart @@ -0,0 +1,842 @@ + +// 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/home_appbar_test.dart b/test/home_appbar_test.dart new file mode 100644 index 0000000..73dddc0 --- /dev/null +++ b/test/home_appbar_test.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Home's app bar carries the rider's identity **and the ON/OFF duty toggle**, +/// and it used to be the first sliver inside the page's `CustomScrollView` — so +/// it scrolled away the moment he looked at his second stop, taking the duty +/// control with it. +/// +/// The fix is structural: the bar becomes the first child of a `Column` and the +/// scrollable takes the rest via `Expanded`. `_HomePageState` cannot be built in +/// a test (Get, SharedPreferences, Geolocator, live fetches), so — as in +/// `card_stripe_layout_test.dart` — these pin the *idiom* rather than the widget. +void main() { + const barKey = ValueKey('appbar'); + const contentKey = ValueKey('tall-content'); + + // The default test viewport is 800×600 and the bar is 96 tall, so this point + // is always inside the scroll area below it. Gestures are aimed here rather + // than at the content's centre: the content is 2000pt tall, so its centre is + // off-screen and a drag there hit-tests against nothing. + const inScrollArea = Offset(400, 350); + + /// The shape Home now uses. + Widget pinned({Future Function()? onRefresh}) { + final scroller = CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Container(key: contentKey, height: 2000, color: Colors.grey), + ), + ], + ); + + return MaterialApp( + home: Scaffold( + body: Stack( + children: [ + Column( + children: [ + Container(key: barKey, height: 96, color: Colors.white), + Expanded( + child: onRefresh == null + ? scroller + : RefreshIndicator(onRefresh: onRefresh, child: scroller), + ), + ], + ), + ], + ), + ), + ); + } + + /// The shape it used to use — bar as a sliver. + Widget scrolling() { + return MaterialApp( + home: Scaffold( + body: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Container(key: barKey, height: 96, color: Colors.white), + ), + SliverToBoxAdapter( + child: Container(key: contentKey, height: 2000, color: Colors.grey), + ), + ], + ), + ), + ); + } + + testWidgets('as a sliver, the bar scrolls off the top', (tester) async { + await tester.pumpWidget(scrolling()); + expect(find.byKey(barKey), findsOneWidget); + + await tester.dragFrom(inScrollArea, const Offset(0, -300)); + await tester.pumpAndSettle(); + + // 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'); + }); + + testWidgets('as a Column child, the bar does not move', (tester) async { + await tester.pumpWidget(pinned()); + final before = tester.getRect(find.byKey(barKey)); + final contentBefore = tester.getRect(find.byKey(contentKey)).top; + + await tester.dragFrom(inScrollArea, const Offset(0, -300)); + await tester.pumpAndSettle(); + + 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)); + }); + + testWidgets('the bar stays put through a long scroll and back', + (tester) async { + await tester.pumpWidget(pinned()); + final rect = tester.getRect(find.byKey(barKey)); + + for (final dy in [-600.0, -600.0, 400.0, -900.0, 1200.0]) { + await tester.dragFrom(inScrollArea, Offset(0, dy)); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byKey(barKey)), rect); + } + }); + + 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)); + })); + final rect = tester.getRect(find.byKey(barKey)); + + await tester.flingFrom(inScrollArea, const Offset(0, 320), 1200); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + + expect(fired, 1, reason: 'the gesture still reaches the scroll view'); + // The spinner must not drag the bar down with it, which is what the old + // unbounded-overscroll page did to the whole header. + expect(tester.getRect(find.byKey(barKey)), rect); + 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)); + })); + + await tester.flingFrom(inScrollArea, const Offset(0, 320), 1200); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + final spinner = find.byType(RefreshProgressIndicator); + 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)); + await tester.pumpAndSettle(); + }); +} diff --git a/test/intro_screen_test.dart b/test/intro_screen_test.dart new file mode 100644 index 0000000..b16ea5c --- /dev/null +++ b/test/intro_screen_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/helpers/constants/app_theme.dart'; +import 'package:miler/views/introscreens/introscreen.dart'; + +/// The first-run slides. The illustrations are miniature mock-ups of the real +/// Home screen rather than a centred Material icon, which means they are made of +/// fixed-width rows — the hub rail on slide 1 is a dozen sized children in a +/// single unflexed `Row`. That is exactly the shape that overflows on a narrow +/// phone, so every slide is laid out at the narrowest screen the app supports. +void main() { + Future pump(WidgetTester tester, {required Size screen}) async { + tester.view.physicalSize = Size(screen.width * 3, screen.height * 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( + theme: AppTheme.light, + home: const Introscreen(), + ), + ), + ); + await tester.pumpAndSettle(); + } + + for (final size in const [ + Size(320, 640), // smallest Android still in the fleet + Size(360, 740), + Size(390, 844), + Size(430, 932), + ]) { + testWidgets('all three slides lay out at ${size.width.toInt()}pt', + (tester) async { + await pump(tester, screen: size); + expect(tester.takeException(), isNull); + + // Slide 1 — the slot, its load, and the round trip. + expect(find.text('Your route arrives ready'), findsOneWidget); + expect(find.text('12:00 PM – 3:00 PM'), findsOneWidget); + expect(find.text('Next'), findsOneWidget); + + // A near-full-width drag, so the page always snaps to the next one. A + // velocity fling does not reliably cross the threshold here, because each + // slide hosts its own vertical scroll view and the two share the arena. + final swipe = Offset(-size.width * 0.9, 0); + + await tester.drag(find.byType(PageView), swipe); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Work the stops in order'), findsOneWidget); + expect(find.text('Skipped'), findsOneWidget); + + await tester.drag(find.byType(PageView), swipe); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Collect, then close the loop'), findsOneWidget); + expect(find.text('₹340 cash'), findsOneWidget); + + // Last slide swaps the CTA and drops Skip. + expect(find.text('Get started'), findsOneWidget); + expect(find.text('Skip'), findsNothing); + }); + } + + testWidgets('Skip is offered on the first slide', (tester) async { + await pump(tester, screen: const Size(390, 844)); + expect(find.text('Skip'), findsOneWidget); + }); + + // 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 { + await pump(tester, screen: const Size(390, 844)); + for (final wrong in [ + 'Find pickups near you', + 'Navigate the smart way', + 'Earn more, every shift', + 'Powered by AI', + ]) { + expect(find.text(wrong), findsNothing, reason: '"$wrong" is not this app'); + } + }); +} diff --git a/test/offline_banner_test.dart b/test/offline_banner_test.dart new file mode 100644 index 0000000..d5abb79 --- /dev/null +++ b/test/offline_banner_test.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/offline/offline_banner.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; + +/// ── Losing signal must not take the job away ── +/// +/// Connectivity used to stack an opaque full-screen page over the entire app. +/// A rider at a doorstep in a basement lost the address, the customer, the +/// parcel count and the OTP field — while holding the parcel. Everything he +/// needs at the door was already fetched; the network is needed to *report* the +/// stop, not to work it. +void main() { + Future pump(WidgetTester tester, {required bool offline}) 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: (_, _) => MaterialApp( + home: OfflineScope( + offline: offline, + child: Scaffold( + body: Column( + children: const [ + Text('45, Lake View Apartments'), + Text('Priya Sharma'), + Text('Delivery OTP'), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('the job stays on screen while offline', (tester) async { + await pump(tester, offline: true); + + // The four things a rider cannot do the stop without. + expect(find.text('45, Lake View Apartments'), findsOneWidget); + expect(find.text('Priya Sharma'), findsOneWidget); + expect(find.text('Delivery OTP'), findsOneWidget); + expect(find.byType(OfflineBanner), findsOneWidget); + }); + + testWidgets('the strip takes its own row — it never covers content', ( + tester, + ) async { + await pump(tester, offline: true); + + // A Column, not a Stack, on purpose: a floating bar over the page would sit + // on top of the OTP box, which is worse than the blocker it replaced. + final strip = tester.getRect(find.byType(OfflineBanner)); + final firstContent = tester.getRect(find.text('45, Lake View Apartments')); + expect(strip.bottom, lessThanOrEqualTo(firstContent.top)); + }); + + testWidgets('it is thin enough to cost the job nothing', (tester) async { + await pump(tester, offline: true); + // Roughly one line plus padding. A "banner" that eats 15% of an 844pt + // screen is a blocker with a nicer name. + expect(tester.getSize(find.byType(OfflineBanner)).height, lessThan(64)); + }); + + testWidgets('it says what happens next, not just what is wrong', ( + tester, + ) async { + await pump(tester, offline: true); + final text = tester + .widgetList( + find.descendant( + of: find.byType(OfflineBanner), + matching: find.byType(Text), + ), + ) + .first + .data!; + expect(text, contains('Offline')); + expect(text.toLowerCase(), contains('sync')); + }); + + 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)); + }); + + testWidgets('the strip carries a readable label on its fill', (tester) async { + await pump(tester, offline: true); + final material = tester.widget( + find + .descendant( + of: find.byType(OfflineBanner), + matching: find.byType(Material), + ) + .first, + ); + // `warning`, which the contrast contract holds to 4.5:1 behind white. + expect(material.color, ColorConstants.warning); + }); +} diff --git a/test/profile_pages_test.dart b/test/profile_pages_test.dart new file mode 100644 index 0000000..d730a2f --- /dev/null +++ b/test/profile_pages_test.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/profile/informations/faq.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/constants/miler_type.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; + +/// The pages you can navigate *into* from Account. +/// +/// ── Why these needed a test ── +/// +/// Eight pushed pages had six different app bars between them: four solid brand +/// red with white text, two white with black text, one grey, one white with a +/// red title. The back arrow came in four different icons at four sizes in three +/// colours. Two shared helpers already existed and, between them, were used by +/// three pages. +/// +/// And the FAQ page **overflowed at any text scale at or above 1.5** — three +/// separate places, on every screen width — which is a black-and-yellow band +/// across a help page for the riders most likely to need help reading it. +/// +/// Both classes of bug are invisible in a screenshot taken at default settings +/// on the developer's own phone, so both are measured here instead. +void main() { + Future pump( + WidgetTester tester, + Widget page, { + double width = 390, + double textScale = 1.0, + }) async { + tester.view.physicalSize = Size(width * 3, 800 * 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( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: page, + ), + ), + ); + await tester.pumpAndSettle(); + } + + group('the shared pushed-page bar', () { + testWidgets('is a glass surface with a slate 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'); + + // ── 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'); + }); + + testWidgets('carries a back affordance when given one', (tester) async { + var popped = false; + await pump( + tester, + Scaffold(appBar: milerPageBar('Notifications', onBack: () => popped = true)), + ); + + // ── An arrow, because these pages are pushed ── + // + // 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); + + await tester.tap(find.byIcon(Icons.arrow_back_rounded)); + 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); + }); + + testWidgets('a modal presentation still gets a cross', (tester) async { + await pump( + tester, + Scaffold( + appBar: milerPageBar('Skip stop', onBack: () {}, isModal: true), + ), + ); + expect(find.byIcon(Icons.close_rounded), findsOneWidget); + expect(find.byIcon(Icons.arrow_back_rounded), findsNothing); + }); + + testWidgets('survives a long title and a large font', (tester) async { + await pump( + tester, + Scaffold( + appBar: milerPageBar( + 'A Very Long Page Title That Will Not Fit', + onBack: () {}, + ), + ), + width: 320, + textScale: 2.0, + ); + expect(tester.takeException(), isNull); + }); + + // ── One line, not two ── + // + // The bar used to carry a caption under the title. It was the smallest type + // on the screen in the one place a rider reads exactly once, and every + // caption duplicated something the page said louder further down — two + // sources for one number, which is how they drift apart. + testWidgets('carries the page name and nothing under it', (tester) async { + await pump(tester, Scaffold(appBar: milerPageBar('Notifications'))); + + final texts = find.byType(Text); + expect(tester.widgetList(texts), hasLength(1)); + expect(find.text('Notifications'), findsOneWidget); + }); + + // ── The × is stacked ABOVE the heading, on the same margin ── + // + // It used to share the row: `[×] Edit Profile`. That pushed every title on + // every pushed page 52pt in from the margin, out of line with the content + // beneath it, and a control sitting at a title's left at the same optical + // weight reads as part of the title rather than as something you press. + testWidgets('stacks the close above the title', (tester) async { + await pump( + tester, + Scaffold(appBar: milerPageBar('Edit Profile', onBack: () {})), + ); + + final close = tester.getRect(find.byIcon(Icons.arrow_back_rounded)); + final title = tester.getRect(find.text('Edit Profile')); + + expect( + close.bottom, + lessThanOrEqualTo(title.top), + reason: 'the back control must sit on its own row above the heading', + ); + expect( + close.left, + closeTo(title.left, 0.5), + reason: + 'they share the page margin — an IconButton could not do this, ' + 'which is why the close is hand-rolled. See _TopBarClose.', + ); + }); + + testWidgets('gives the back control a full 48pt target', (tester) async { + await pump( + tester, + Scaffold(appBar: milerPageBar('Edit Profile', onBack: () {})), + ); + final box = tester.getSize( + find.ancestor( + of: find.byIcon(Icons.arrow_back_rounded), + 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. + expect(box.width, 48); + expect(box.height, 48); + }); + }); + + group('FAQ lays out at every font scale', () { + // It did not. `Container(height: 160.h)` around the hero overflowed by 89px + // at 1.5x; the category grid's `childAspectRatio` derived cell height from + // the column *width*, so all four tiles overflowed by ~10px; and the SOS + // row's two unflexed children ran 30px past the card. + 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'); + + // 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'); + } + }); + } + } + }); + + // Terms & Conditions is deliberately not laid out here: it hosts a + // `WebView`, and `WebViewPlatform.instance` has no implementation in a headless + // unit test, so the page cannot be built at all. Its app bar is the shared one + // — that much is covered by the group above — but the body needs a device or + // an integration test. +} diff --git a/test/review_grid_test.dart b/test/review_grid_test.dart new file mode 100644 index 0000000..b29717d --- /dev/null +++ b/test/review_grid_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/pickups/stop_verify.dart'; + +/// The review step is the last screen before a stop is closed, and the only +/// thing the rider does on it is check figures. It used to spend a full line on +/// each — an icon, a caption and a value — for values that are almost always +/// four characters, so six facts ran past the fold and he had to scroll a +/// *confirmation* screen to see what he was confirming. +/// +/// The packing rule is what fixed that, and it is asserted here rather than on +/// screen: the review sits behind a camera gate no widget test can pass. +void main() { + ReviewFact narrow(String label, String value) => + ReviewFact(label, value, icon: Icons.circle); + + 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]]; + + test('two short facts share a row', () { + final rows = packReviewFacts([ + narrow('Parcels', '3 of 3'), + narrow('Weight', '4.5 kg'), + ]); + + expect(labels(rows), [ + ['Parcels', 'Weight'], + ]); + }); + + test('a sentence takes its own row', () { + // Wrapping a recipient's name or a damage note into half a column is how a + // grid turns back into a wall of text. + final rows = packReviewFacts([ + narrow('Parcels', '3 of 3'), + narrow('Weight', '4.5 kg'), + wide('Note', 'Corner of the second box is crushed'), + ]); + + expect(labels(rows), [ + ['Parcels', 'Weight'], + ['Note'], + ]); + }); + + test('a wide fact does not drag its neighbour onto its row', () { + final rows = packReviewFacts([ + wide('Received by', 'Ramesh Kumar · Security'), + narrow('OTP', '482156'), + ]); + + expect(labels(rows), [ + ['Received by'], + ['OTP'], + ]); + }); + + test('empty values are dropped, not rendered as dashes', () { + final rows = packReviewFacts([ + narrow('Parcels', '3 of 3'), + narrow('Pickup code', ''), + narrow('Weight', '4.5 kg'), + ]); + + expect(labels(rows), [ + ['Parcels', 'Weight'], + ]); + }); + + test('order is meaning, so nothing is re-sorted to pack tighter', () { + final rows = packReviewFacts([ + narrow('Parcels', '3 of 3'), + wide('Note', 'Shutter was half down'), + narrow('Weight', '4.5 kg'), + narrow('Condition', 'Sealed & intact'), + ]); + + // A tighter packing exists — pair Parcels with Weight and push the note + // down — and it is deliberately not taken. + expect(labels(rows), [ + ['Parcels'], + ['Note'], + ['Weight', 'Condition'], + ]); + }); + + test('the collect leg fits in three rows instead of six', () { + final rows = packReviewFacts([ + narrow('Parcels', '3 of 3'), + narrow('Weight', '4.5 kg'), + narrow('Condition', 'Sealed & intact'), + narrow('Pickup code', '482156'), + wide('Note', 'Customer only had two ready'), + ]); + + expect(rows, hasLength(3)); + }); +} diff --git a/test/rider_tier_test.dart b/test/rider_tier_test.dart new file mode 100644 index 0000000..881156f --- /dev/null +++ b/test/rider_tier_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/rider_tier.dart'; + +/// The ladder behind the Rewards page. Modelled on Uber Pro: points earned over +/// a fixed period decide the status, and the screen's job is to answer "how far +/// is the next one" in points a rider can act on today. +void main() { + group('standing', () { + test('starts at the bottom rung', () { + final s = TierStanding.of(0); + expect(s.tier, RiderTier.blue); + expect(s.nextTier, RiderTier.silver); + expect(s.pointsToNext, kTierThresholds[RiderTier.silver]); + expect(s.progressToNext, 0); + }); + + test('lands on the highest threshold passed, not the nearest', () { + expect(TierStanding.of(149).tier, RiderTier.blue); + expect(TierStanding.of(150).tier, RiderTier.silver); + expect(TierStanding.of(399).tier, RiderTier.silver); + expect(TierStanding.of(400).tier, RiderTier.gold); + }); + + // A rider one point into Gold should see an empty bar to Platinum, not a + // bar already half full because his lifetime number is big. + test('progress is measured across the current band', () { + final justIn = TierStanding.of(400); + expect(justIn.tier, RiderTier.gold); + expect(justIn.progressToNext, 0); + + final halfway = TierStanding.of(600); // 400 → 800 + expect(halfway.progressToNext, closeTo(0.5, 0.001)); + }); + + test('the top of the ladder has nothing left to chase', () { + final s = TierStanding.of(5000); + expect(s.tier, RiderTier.platinum); + expect(s.nextTier, isNull); + expect(s.pointsToNext, isNull); + expect(s.progressToNext, 1); + }); + + test('every tier knows the one above it, and the last one does not', () { + expect(RiderTier.blue.next, RiderTier.silver); + expect(RiderTier.gold.next, RiderTier.platinum); + expect(RiderTier.platinum.next, isNull); + }); + }); + + group('reward period', () { + test('is the calendar month, whole and inclusive', () { + final p = RewardPeriod.current(DateTime(2026, 8, 5)); + expect(p.start, DateTime(2026, 8, 1)); + expect(p.end, DateTime(2026, 8, 31)); + expect(p.label, 'August 2026'); + }); + + test('counts today as a day left, and never goes negative', () { + final p = RewardPeriod.current(DateTime(2026, 8, 30)); + expect(p.daysLeft(DateTime(2026, 8, 30)), 2); + expect(p.daysLeft(DateTime(2026, 8, 31)), 1); + expect(p.daysLeft(DateTime(2026, 9, 4)), 0); + }); + + test('handles a December period rolling into the new year', () { + final p = RewardPeriod.current(DateTime(2026, 12, 10)); + expect(p.end, DateTime(2026, 12, 31)); + expect(p.label, 'December 2026'); + }); + }); +} diff --git a/test/route_brief_test.dart b/test/route_brief_test.dart new file mode 100644 index 0000000..41634bf --- /dev/null +++ b/test/route_brief_test.dart @@ -0,0 +1,313 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:miler/views/Dashboard/home/route_brief.dart'; + +void main() { + // Two stops roughly 2.5 km and 3.5 km from a Coimbatore origin. + final stops = >{ + 'A': { + 'Quantity': '3', + 'weight': '4.5', + 'collectionamt': '250', + 'pickuplat': '11.0243', + 'pickuplon': '76.9558', + 'kms': '2.1', + }, + 'B': { + 'Quantity': '8', + 'weight': '2', + 'collectionamt': '0', + 'pickuplat': '11.0312', + 'pickuplon': '76.9678', + 'kms': '1.5', + }, + }; + + group('RouteBrief load & capacity', () { + test('sums parcels, weight and cash across the route', () { + final brief = RouteBrief.from( + orderIds: const ['A', 'B'], + ordersMap: stops, + shiftStart: '09:00:00', + shiftEnd: '18:00:00', + ); + + expect(brief.stops, 2); + expect(brief.parcels, 11); + expect(brief.weightKg, 6.5); + expect(brief.collectAmount, 250); + }); + + test('counts accepted stops that already left the pending list', () { + final brief = RouteBrief.from( + orderIds: const ['A'], + ordersMap: stops, + shiftStart: '', + shiftEnd: '', + extraStops: [stops['B']!], + ); + + // Same totals as if both were still pending — an accepted stop is still + // a stop the rider has to ride to. + expect(brief.stops, 2); + expect(brief.parcels, 11); + expect(brief.weightKg, 6.5); + }); + + test('strips units baked into numeric strings', () { + final brief = RouteBrief.from( + orderIds: const ['A'], + ordersMap: { + 'A': {'Quantity': '2', 'weight': '3.25 kg', 'collectionamt': '₹180'}, + }, + shiftStart: '', + shiftEnd: '', + ); + + expect(brief.weightKg, 3.25); + expect(brief.collectAmount, 180); + }); + }); + + group('RouteBrief round-trip distance', () { + test('closes the loop back to the origin', () { + final withOrigin = RouteBrief.from( + orderIds: const ['A', 'B'], + ordersMap: stops, + shiftStart: '', + shiftEnd: '', + riderLat: 11.0180, + riderLng: 76.9580, + ); + final withoutOrigin = RouteBrief.from( + orderIds: const ['A', 'B'], + ordersMap: stops, + shiftStart: '', + shiftEnd: '', + ); + + // Origin → A → B → origin must be longer than the bare A → B leg. + expect(withOrigin.routeKm, greaterThan(withoutOrigin.routeKm)); + expect(withOrigin.routeKm, greaterThan(4)); + 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: '', + ); + + expect(brief.routeKm, 4.2); + }); + }); + + group('RouteBrief shift window', () { + test('flags a missing shift', () { + final brief = RouteBrief.from( + orderIds: const [], + ordersMap: const {}, + shiftStart: '', + shiftEnd: '', + ); + + expect(brief.shiftSet, isFalse); + expect(brief.timeLeft, isNull); + expect(brief.shiftWindowLabel, 'Shift not set'); + }); + + test('flags a half-set shift as not set', () { + final brief = RouteBrief.from( + orderIds: const [], + ordersMap: const {}, + shiftStart: '09:00:00', + shiftEnd: '', + ); + + expect(brief.shiftSet, isFalse); + }); + + test('computes time left and a per-stop budget', () { + final now = DateTime(2026, 7, 27, 15, 0); + final brief = RouteBrief.from( + orderIds: const ['A', 'B'], + ordersMap: stops, + shiftStart: '09:00:00', + shiftEnd: '18:00:00', + now: now, + ); + + expect(brief.shiftSet, isTrue); + expect(brief.timeLeft, const Duration(hours: 3)); + expect(brief.perStopBudget, const Duration(minutes: 90)); + expect(brief.isPaceAtRisk, isFalse); + expect(brief.shiftWindowLabel, '9:00 AM – 6:00 PM'); + }); + + test('clamps a finished shift to zero instead of going negative', () { + final brief = RouteBrief.from( + orderIds: const [], + ordersMap: const {}, + shiftStart: '09:00:00', + shiftEnd: '18:00:00', + now: DateTime(2026, 7, 27, 20, 0), + ); + + expect(brief.timeLeft, Duration.zero); + expect(brief.perStopBudget, isNull); + }); + + test('rolls an overnight shift end into the next day', () { + final brief = RouteBrief.from( + orderIds: const [], + ordersMap: const {}, + shiftStart: '22:00:00', + shiftEnd: '06:00:00', + now: DateTime(2026, 7, 27, 23, 0), + ); + + expect(brief.timeLeft, const Duration(hours: 7)); + }); + + test('raises the pace flag when stops cannot fit in the time left', () { + final brief = RouteBrief.from( + orderIds: const ['A', 'B'], + ordersMap: stops, + shiftStart: '09:00:00', + shiftEnd: '18:00:00', + now: DateTime(2026, 7, 27, 17, 51), // 9 minutes for 2 stops + ); + + expect(brief.isPaceAtRisk, isTrue); + }); + + test('parses 12-hour shift strings', () { + final brief = RouteBrief.from( + orderIds: const [], + ordersMap: const {}, + shiftStart: '9:30 AM', + shiftEnd: '6:00 PM', + now: DateTime(2026, 7, 27, 12, 0), + ); + + expect(brief.shiftSet, isTrue); + expect(brief.timeLeft, const Duration(hours: 6)); + }); + }); + + /// A missing shift is somebody else's admin: the rider cannot fix it, did not + /// cause it, and is not blocked by it — the trips still load and he can still + /// ride them. So the banner says nothing at all rather than parking a + /// permanent amber "No shift set for today" box at the top of his first + /// screen. These tests keep it that way, because a box like that comes back + /// easily and always with good intentions. + group('ShiftBanner', () { + Future pump(WidgetTester tester, RouteBrief brief) async { + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold(body: ShiftBanner(brief: brief)), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('renders nothing at all when no shift is set', (tester) async { + await pump( + tester, + RouteBrief.from( + orderIds: const ['A', 'B'], + ordersMap: stops, + shiftStart: '', + shiftEnd: '', + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('No shift set for today'), findsNothing); + expect(find.text('Retry'), findsNothing); + expect(find.textContaining('hub manager'), findsNothing); + + // Not merely empty of those strings — the widget takes no height, so the + // route below it starts at the top of the page. + expect(tester.getSize(find.byType(ShiftBanner)).height, 0); + }); + + testWidgets('still shows the window once the hub sets one', (tester) async { + await pump( + tester, + RouteBrief.from( + orderIds: const ['A', 'B'], + ordersMap: stops, + shiftStart: '09:00:00', + shiftEnd: '18:00:00', + now: DateTime(2026, 7, 27, 15, 0), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('3h left'), findsOneWidget); + expect(tester.getSize(find.byType(ShiftBanner)).height, greaterThan(0)); + }); + }); + + group('RouteBrief time windows', () { + final now = DateTime(2026, 7, 27, 15, 0); + + test('counts stops past their slot and stops closing soon', () { + final brief = RouteBrief.from( + orderIds: const ['late', 'soon', 'later'], + ordersMap: { + 'late': { + 'expected_pickup_time': + now.subtract(const Duration(minutes: 20)).toIso8601String(), + }, + 'soon': { + 'expected_pickup_time': + now.add(const Duration(minutes: 10)).toIso8601String(), + }, + 'later': { + 'expected_pickup_time': + now.add(const Duration(hours: 3)).toIso8601String(), + }, + }, + shiftStart: '09:00:00', + shiftEnd: '18:00:00', + now: now, + ); + + expect(brief.overdueStops, 1); + expect(brief.dueSoonStops, 1); + }); + + test('ignores stops with no slot time', () { + final brief = RouteBrief.from( + orderIds: const ['A', 'B'], + ordersMap: stops, + shiftStart: '09:00:00', + shiftEnd: '18:00:00', + now: now, + ); + + expect(brief.overdueStops, 0); + expect(brief.dueSoonStops, 0); + }); + }); + + group('formatDuration', () { + test('reads like a clock', () { + expect(formatDuration(const Duration(minutes: 45)), '45m'); + expect(formatDuration(const Duration(hours: 2, minutes: 15)), '2h 15m'); + expect(formatDuration(const Duration(hours: 3)), '3h'); + expect(formatDuration(const Duration(seconds: 20)), 'under a minute'); + }); + }); +} diff --git a/test/route_flow_test.dart b/test/route_flow_test.dart new file mode 100644 index 0000000..9551407 --- /dev/null +++ b/test/route_flow_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/pickups/pickups.dart'; + +/// The route flow on the stop map used to be a comet: a window covering 18% of +/// the route, sliding from one end to the other. At any moment the rider saw a +/// short white dash somewhere in the middle of his journey, which reads as a +/// decoration on the map rather than as a statement about his trip — and on a +/// long route the dash never visibly connected the two ends at all. +/// +/// It is now one continuous stream: white grows from the rider's own position to +/// the stop, holds, then drains back down the line to him, forever. The map, the +/// ticker and the live route make that untestable as a widget — but the claims +/// are claims about numbers, and these are the numbers. +void main() { + // Representative shares: a ~2.4s grow, a beat, a ~0.95s drain. + const grow = 0.66; + const hold = 0.07; + + ({RouteFlowPhase phase, double progress}) at(double t) => + routeFlowFrame(t, growShare: grow, holdShare: hold); + + group('the cycle', () { + test('starts at the rider, with nothing drawn ahead of him', () { + final f = at(0); + expect(f.phase, RouteFlowPhase.grow); + // Head at 0 of the route's length: the stream begins exactly at the + // rider's own position, not somewhere along the line. + expect(f.progress, 0); + }); + + test('grows at a constant rate all the way to the stop', () { + // Linear, not eased. The draw-in is eased because it is an entrance; this + // is travel, and an eased stream appears to slow down in the middle of the + // road for no reason. + double head(double t) => at(t).progress; + final quarter = head(grow * 0.25); + final half = head(grow * 0.5); + final threeQuarters = head(grow * 0.75); + + expect(quarter, closeTo(0.25, 1e-9)); + expect(half, closeTo(0.5, 1e-9)); + expect(threeQuarters, closeTo(0.75, 1e-9)); + + // Equal time, equal distance — at every point in the phase. + expect(half - quarter, closeTo(threeQuarters - half, 1e-9)); + }); + + test('reaches the destination exactly at the end of the grow', () { + expect(at(grow).phase, RouteFlowPhase.grow); + expect(at(grow).progress, closeTo(1.0, 1e-9)); + }); + + test('holds at full length before draining', () { + final f = at(grow + hold / 2); + expect(f.phase, RouteFlowPhase.hold); + }); + + test('drains for the rest of the cycle, and is finished at the end', () { + expect(at(grow + hold + 0.001).phase, RouteFlowPhase.fade); + final end = at(1); + expect(end.phase, RouteFlowPhase.fade); + expect(end.progress, closeTo(1.0, 1e-9)); + }); + + test('the phases tile the cycle with no gap and no overlap', () { + // A gap is a frame with nothing drawn; an overlap is a frame drawn twice. + // Either shows up as a stutter at the seam. + RouteFlowPhase? previous; + final seen = []; + for (var i = 0; i <= 1000; i++) { + final phase = at(i / 1000).phase; + if (phase != previous) seen.add(phase); + previous = phase; + } + expect(seen, [ + RouteFlowPhase.grow, + RouteFlowPhase.hold, + RouteFlowPhase.fade, + ]); + }); + }); + + group('the drain', () { + test('the line is whole the instant the fade begins', () { + // Not already clipped at the far end — which is what made the old loop + // appear to restart before it had arrived. + for (final x in kRouteFlowStops) { + expect(routeFlowFadeAlpha(0, x), closeTo(1.0, 1e-9)); + } + }); + + test('nothing is left when the fade ends', () { + for (final x in kRouteFlowStops) { + expect(routeFlowFadeAlpha(1, x), 0); + } + }); + + test('it drains from the destination back towards the rider', () { + // At any partial moment the stop end is at most as bright as the rider + // end: the front travels backwards down the route, it does not dissolve + // the whole line at once. + for (var i = 1; i < 10; i++) { + final f = i / 10; + final atRider = routeFlowFadeAlpha(f, 0); + final atStop = routeFlowFadeAlpha(f, 1); + expect( + atStop, + lessThanOrEqualTo(atRider + 1e-9), + reason: 'at f=$f the stop end should lead the fade', + ); + } + + // And the rider's end is the last thing still lit. + expect(routeFlowFadeAlpha(0.6, 1), 0); + expect(routeFlowFadeAlpha(0.6, 0), greaterThan(0)); + }); + + test('it is soft, not a wipe', () { + // Mid-drain there is a ramp rather than a hard edge: at least one stop + // sits strictly between lit and dark, which is the whole difference + // between a fade and a shutter. + final partial = [ + for (final x in kRouteFlowStops) routeFlowFadeAlpha(0.45, x), + ]; + expect(partial.any((a) => a > 0.01 && a < 0.99), isTrue); + }); + + test('every point only ever gets dimmer', () { + for (final x in kRouteFlowStops) { + var previous = double.infinity; + for (var i = 0; i <= 100; i++) { + final a = routeFlowFadeAlpha(i / 100, x); + expect(a, lessThanOrEqualTo(previous + 1e-9)); + previous = a; + } + } + }); + }); + + group('degenerate shares', () { + test('a cycle with no hold still tiles', () { + final f = routeFlowFrame(0.9, growShare: 0.8, holdShare: 0); + expect(f.phase, RouteFlowPhase.fade); + expect(f.progress, closeTo(0.5, 1e-9)); + }); + + test('a cycle with no grow starts already arrived', () { + final f = routeFlowFrame(0, growShare: 0, holdShare: 0.1); + expect(f.phase, RouteFlowPhase.hold); + }); + + 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(routeFlowFadeAlpha(-1, 0.5), 1); + expect(routeFlowFadeAlpha(9, 0.5), 0); + }); + }); +} diff --git a/test/selection_bar_test.dart b/test/selection_bar_test.dart new file mode 100644 index 0000000..3b4f767 --- /dev/null +++ b/test/selection_bar_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/home/selection_bar.dart'; + +/// The bar is the only place a decision is committed now that stop rows are +/// tick-only, so it has to carry BOTH decisions — and carry them at every +/// width, since the labels sit beside a live count. +void main() { + Future pump( + WidgetTester tester, { + required int count, + required double width, + bool withReject = true, + }) async { + tester.view.physicalSize = Size(width * 3, 2532); + 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: count, + onAccept: () {}, + onReject: withReject ? () {} : null, + onClear: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + 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('Reject'), findsOneWidget); + }); + + testWidgets('reject fires with the selection', (tester) async { + var rejected = 0; + tester.view.physicalSize = const Size(1170, 2532); + 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: 2, + onAccept: () {}, + onReject: () => rejected++, + onClear: () {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Reject')); + await tester.pumpAndSettle(); + expect(rejected, 1); + }); + + 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); + }); + + // Two buttons and a count on one line is exactly the shape that overflows on + // a small phone, and an overflowing action bar is the one place the rider + // cannot work around it. + for (final width in [320.0, 360.0, 390.0, 430.0]) { + testWidgets('lays out at ${width.toInt()}pt', (tester) async { + await pump(tester, count: 12, width: width); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/skipped_store_test.dart b/test/skipped_store_test.dart new file mode 100644 index 0000000..a77ba90 --- /dev/null +++ b/test/skipped_store_test.dart @@ -0,0 +1,88 @@ +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'; + +/// A skip is now shown on Activity rather than Home, and Activity reads its own +/// endpoints — which do not report a skip for a poll or two, and never on demo +/// data. So the store is what makes a skipped stop visible at all, and these +/// are the properties the two screens rely on: +/// +/// • it survives the app being killed mid-shift; +/// • it stamps the stop so one `stopStatusOf` test recognises it everywhere; +/// • it forgets a resumed stop, or Activity would keep offering Resume for +/// something already back on Bookings; +/// • 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', + }; + + setUp(() => SharedPreferences.setMockInitialValues({})); + + 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'}); + }); + + test('the same stop skipped twice is one row, not two', () async { + await addSkippedBooking(booking('A'), reason: 'Customer unreachable'); + await addSkippedBooking(booking('A'), reason: 'Shutter closed'); + + final stored = await getSkippedBookings(); + expect(stored, hasLength(1)); + expect(stored.single['skipreason'], 'Shutter closed'); + }); + + test('resuming forgets the skip', () async { + await addSkippedBooking(booking('A'), reason: 'Customer unreachable'); + await addSkippedBooking(booking('B'), reason: 'Not at the location'); + + await removeSkippedBookings(['A']); + + expect(await getSkippedOrderIds(), {'B'}); + }); + + test('yesterday is dropped on read', () async { + // Written the way the store writes, but stamped for another day. + final stale = booking('A') + ..['orderstatus'] = 'skipped' + ..['skipreason'] = 'Customer unreachable' + ..['skippedat'] = '2020-01-01T09:00:00.000' + ..['skippedday'] = '2020-01-01'; + SharedPreferences.setMockInitialValues({ + '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); + }); + + test('newest first — the stop he just put down leads the list', () async { + await addSkippedBooking(booking('A'), reason: 'Customer unreachable'); + await Future.delayed(const Duration(milliseconds: 5)); + await addSkippedBooking(booking('B'), reason: 'Shutter closed'); + + final stored = await getSkippedBookings(); + expect(stored.map((s) => s['orderid']), ['B', 'A']); + }); +} diff --git a/test/stop_card_neutral_test.dart b/test/stop_card_neutral_test.dart new file mode 100644 index 0000000..0f7dfee --- /dev/null +++ b/test/stop_card_neutral_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/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_card.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; + +/// ── Stop type may be a mark, never a surface ── +/// +/// Pending stop cards used to be *filled* with their stop-type accent at 5% and +/// bordered with the same colour at 38%, so the route came down the screen as a +/// lavender pane, then a pink one, then a blue one. +/// +/// The line drawn now is between a mark and a surface. A small filled chip +/// naming the mode is a mark: it is a tag, it is bounded, and it does not colour +/// anything the rider is reading through. A card fill is a surface, and tinting +/// it puts a wash behind the name, the address and the buttons — which is where +/// Uber's Base rule bites: surfaces are white or one neutral grey, and the +/// accent is reserved for "one moment per screen". +/// +/// So the type colours are allowed to appear, on a chip, and the chip fills use +/// the *darkened* variants ([ColorConstants.pickupChip] and friends) so white +/// text on them clears AA. What they may not do is fill or border a card. +/// +/// These tests pin that by colour value, which is the only way it stays pinned — +/// a comment saying "keep this neutral" does not survive a redesign. +/// +/// Note on coverage: `pickupAccent` is an alias of `ColorConstants.primary`, so +/// it is indistinguishable from the legitimate brand accent and cannot be +/// asserted against. +void main() { + Map stop( + String id, { + required String type, + 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(), + }; + + 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); + addTearDown(tester.view.resetDevicePixelRatio); + + final trip = Trip.fromStops( + id: 'mixed', + stops: [ + stop('P', type: 'pickup', pickupQty: 4), + stop('D', type: 'delivery', deliverQty: 2), + stop('C', type: 'combined', deliverQty: 1, pickupQty: 2, cash: 340), + ], + ); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: trip, + acceptedIds: accepted, + onToggleSelect: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// Every colour the card paints: decoration fills, border sides, icon tints + /// and text colours (including rich-text spans). + List<(String, Color)> paintedColours(WidgetTester tester) { + final found = <(String, Color)>[]; + + 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)); + } + } + } + + // Walks `children` directly rather than via `visitChildren`: that visitor + // yields the span itself before its children, so recursing from inside it + // re-enters on the same node forever. + void addSpan(String where, InlineSpan? span) { + if (span == null) return; + final c = span.style?.color; + if (c != null) found.add(('$where.span', c)); + if (span is TextSpan) { + for (final child in span.children ?? const []) { + addSpan(where, child); + } + } + } + + for (final w in tester.allWidgets) { + switch (w) { + case Container(:final decoration): + if (decoration is BoxDecoration) { + if (decoration.color != null) { + found.add(('Container.color', decoration.color!)); + } + addBorder('Container', decoration.border); + } + case DecoratedBox(:final decoration): + if (decoration is BoxDecoration) { + if (decoration.color != null) { + found.add(('DecoratedBox.color', decoration.color!)); + } + addBorder('DecoratedBox', decoration.border); + } + case Icon(:final color) when color != null: + found.add(('Icon', color)); + case Text(:final style, :final textSpan): + if (style?.color != null) found.add(('Text', style!.color!)); + addSpan('Text', textSpan); + case RichText(:final text): + addSpan('RichText', text); + default: + break; + } + } + return found; + } + + /// The *undarkened* accents. These are the values that were washing the card + /// fills and borders; the chip uses the darkened variants instead, so seeing + /// one of these anywhere means a tint has crept back. + final bannedOutright = { + 'deliveryAccent (royal blue)': ColorConstants.deliveryAccent, + 'combinedAccent (deep indigo)': ColorConstants.combinedAccent, + }; + + 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()}'); + } + }); + + testWidgets('the same holds once some stops are accepted', (tester) async { + 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'); + } + }); + + testWidgets('type colour reaches the chip and nothing else', (tester) async { + await pumpCard(tester); + + // Present: the three darkened chip fills, one per stop kind. + final containerFills = tester.allWidgets + .whereType() + .map((c) => c.decoration) + .whereType() + .map((d) => d.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(); + + for (final chip in [ + ColorConstants.pickupChip, + ColorConstants.deliveryChip, + ColorConstants.combinedChip, + ]) { + expect(borderColours, isNot(contains(chip)), + reason: 'stop type must not outline a surface'); + } + }); + + testWidgets('stop cards are borderless, on one fill whatever the 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(); + + 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'); + } + }); + + 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 + // the chip was capped at about 23pt against a 63pt label, i.e. **36% of what + // it needed**, no matter how short the wording was. + // + // Asserted as a *ratio* to the label's own natural width rather than as a + // fixed number of points. The headless test font draws every glyph as a full + // em square — roughly twice a real font — so absolute widths here mean + // nothing, but "how much of what it asked for did it get" survives the + // difference. A flat floor would also wrongly fail "P&D", which is three + // characters and legitimately narrow. + const squeezed = 0.36; // what the broken layout gave + const acceptable = 0.6; + + 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'); + + for (final label in ['PICKUP', 'DELIVERY', 'P&D']) { + final finder = find.text(label); + if (finder.evaluate().isEmpty) continue; + + final box = finder.evaluate().first.renderObject! as RenderBox; + final natural = (TextPainter( + text: TextSpan( + text: label, + style: tester.widget(finder.first).style, + ), + textDirection: TextDirection.ltr, + )..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()}%'); + } + } + }); +} diff --git a/test/stop_compliance_test.dart b/test/stop_compliance_test.dart new file mode 100644 index 0000000..c61dee2 --- /dev/null +++ b/test/stop_compliance_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/data/stop_compliance.dart'; + +/// The two verdicts the hub pays on. Both were already being measured — the +/// bonus rule is "before the ETA" and the distance is measured to bill rider +/// charges — and neither reached the rider until now, so the arithmetic behind +/// them is worth pinning. +void main() { + group('one stop', () { + test('on time and within the route tolerance is clean', () { + const c = StopCompliance(onTime: true, actualKm: 5.4, plannedKm: 5.0); + expect(c.onRoute, isTrue); + expect(c.isClean, isTrue); + expect(c.extraKm, isNull); + }); + + test('a wander past the tolerance is off route, and says by how much', () { + const c = StopCompliance(onTime: true, actualKm: 12.0, plannedKm: 5.0); + expect(c.onRoute, isFalse); + expect(c.extraKm, 7.0); + expect(c.isClean, isFalse, reason: 'both halves have to hold'); + }); + + // Traffic, one-ways and diversions are the rider's normal, not a detour. + test('ordinary slack does not count as leaving the route', () { + 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'); + }); + + test('late is not clean, however good the route', () { + const c = StopCompliance( + onTime: false, + lateBy: Duration(minutes: 9), + actualKm: 4.0, + plannedKm: 5.0, + ); + expect(c.onRoute, isTrue); + expect(c.isClean, isFalse); + }); + + // A hub that set no ETA, a GPS that never resolved: not the rider's fault + // and never shown as a miss. + test('missing inputs are unknown, not failures', () { + const nothing = StopCompliance(); + expect(nothing.isUnknown, isTrue); + expect(nothing.onRoute, isNull); + 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'); + }); + }); + + group('reading a stored stop', () { + test('prefers the stamp written at completion', () { + final c = StopCompliance.of({ + 'compliance': { + 'ontime': true, + 'latebyseconds': 0, + 'actualkm': 4.0, + 'plannedkm': 4.0, + }, + 'riderkms': '99', + 'kms': '1', + }); + expect(c.onTime, isTrue); + expect(c.actualKm, 4.0); + expect(c.onRoute, isTrue); + }); + + test('falls back to the backend row when there is no stamp', () { + final c = StopCompliance.of({'riderkms': '9.0', 'kms': '4'}); + expect(c.onTime, isNull); + expect(c.onRoute, isFalse, reason: '9km against a 4km plan is a wander'); + }); + }); + + group('the day', () { + test('scores only what could be measured', () { + final summary = ComplianceSummary.from([ + { + 'compliance': {'ontime': true, 'actualkm': 4.0, 'plannedkm': 4.0} + }, + { + 'compliance': {'ontime': false, 'actualkm': 4.0, 'plannedkm': 4.0} + }, + { + 'compliance': {'ontime': true, 'actualkm': 20.0, 'plannedkm': 4.0} + }, + {'orderid': 'no-data'}, + ]); + + expect(summary.measured, 3); + expect(summary.unmeasured, 1, reason: 'and it is not in the denominator'); + expect(summary.onTime, 2); + expect(summary.onRoute, 2); + expect(summary.clean, 1); + expect(summary.points, 1, reason: 'a point per stop that cleared both'); + expect(summary.onTimePercent, 67); + }); + + test('an empty day scores nothing rather than dividing by zero', () { + final summary = ComplianceSummary.from(const []); + expect(summary.points, 0); + expect(summary.onTimePercent, 0); + expect(summary.cleanPercent, 0); + }); + }); +} diff --git a/test/stop_row_alignment_test.dart b/test/stop_row_alignment_test.dart new file mode 100644 index 0000000..c6f5267 --- /dev/null +++ b/test/stop_row_alignment_test.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.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. +/// +/// 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. +/// +/// 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. +void main() { + 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(), + }; + + /// 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: + /// + /// 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 { + 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'}, + ]); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: trip, + rejectedIds: const {'C'}, + onToggleSelect: (_) {}, + onCallStop: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('every call button shares one column', (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 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'); + }); + + testWidgets('the call column does not move when the tick box is absent', + (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(); + + expect(callXs.length, 1, + reason: 'a missing tick box must not pull the call button across'); + }); + + testWidgets('the card and the rail each get their side gap', (tester) async { + await pumpRoute(tester); + + // 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'); + + // Nothing is clipped or overflowing at the other edge either. + expect(tester.takeException(), isNull); + }); + + testWidgets('the two controls are the same size', (tester) async { + await pumpRoute(tester); + + final call = tester.getSize(find.byIcon(Icons.call_rounded).first); + expect(call.width, call.height, reason: 'the call button is square'); + + // 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); + }); +} diff --git a/test/stop_verify_chrome_test.dart b/test/stop_verify_chrome_test.dart new file mode 100644 index 0000000..20088d6 --- /dev/null +++ b/test/stop_verify_chrome_test.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/pickups/stop_verify.dart'; +import 'package:miler/views/helpers/constants/Colorconstants.dart'; +import 'package:miler/views/helpers/widgets/app_widgets.dart'; + +/// ── The form is the screen; the boxes were not ── +/// +/// Every group on a verification leg used to be a filled, rounded, 16pt-padded +/// panel — the stop summary, the parcel checklist, the recipient picker, the +/// OTP, the photo — four to six of them stacked, each holding white controls. +/// That put a *container* rank and a *control* rank on the page at the same +/// weight, when only one of the two is something the rider touches at a door. +/// +/// They are named and ruled off instead. These tests pin the parts of that +/// which a later change could quietly undo. +void main() { + Future pump( + WidgetTester tester, + Map stop, { + double width = 390, + }) async { + 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(home: StopVerificationPage(pickup: stop)), + ), + ); + await tester.pumpAndSettle(); + } + + Map stopOf({ + required String type, + int deliveryQty = 0, + int pickupQty = 0, + }) => { + 'orderid': 'T-1', + 'type': type, + 'pickupcustomer': 'Priya Sharma', + 'pickupaddress': '45, Lake View Apartments, Race Course, Coimbatore', + if (deliveryQty > 0) 'deliveryqty': '$deliveryQty', + if (pickupQty > 0) 'pickupqty': '$pickupQty', + 'Quantity': '${pickupQty > 0 ? pickupQty : deliveryQty}', + }; + + /// The width of a chip, measured on the box that actually draws it. + double chipWidth(WidgetTester tester, String label) => tester + .getSize( + find + .ancestor( + of: find.text(label), + matching: find.byType(AnimatedContainer), + ) + .first, + ) + .width; + + testWidgets('a leg step draws no filled panels', (tester) async { + await pump(tester, stopOf(type: 'combined', deliveryQty: 1, pickupQty: 2)); + + // `cardSurface` is the panel fill. It is still right for the review step — + // one grey block per leg, on the screen the rider signs — but on a step he + // is filling in, every one of them was a box around work rather than the + // work itself. + final fills = tester + .widgetList( + find.descendant( + of: find.byType(SingleChildScrollView), + matching: find.byType(Container), + ), + ) + .map((c) => c.decoration) + .whereType() + .map((d) => d.color); + + expect(fills, isNot(contains(ColorConstants.cardSurface))); + }); + + testWidgets('section names are quiet labels, not headings', (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)); + + // Quieter than the stop it belongs to, which is itself only context. + final name = tester.widget(find.text('Priya Sharma')); + expect(label.style!.fontSize, lessThan(name.style!.fontSize!)); + }); + + testWidgets('the pickers are an even grid, with no half-width orphan', ( + tester, + ) async { + await pump(tester, stopOf(type: 'delivery', deliveryQty: 1)); + + // Four recipients: two rows of two, all the same width. Natural-width chips + // packed themselves three-then-one, which reads as a list that ran out + // rather than as a set of four equal choices. + final customer = chipWidth(tester, 'Customer'); + for (final other in const ['Family', 'Security', 'Neighbour']) { + expect(chipWidth(tester, other), closeTo(customer, 0.5)); + } + }); + + testWidgets('an odd picker gives its last option the full row', ( + tester, + ) async { + await pump(tester, stopOf(type: 'pickup', pickupQty: 2)); + + // Three conditions. The third at half width on a line of its own is an + // orphan; at full width it is a second row. + expect( + chipWidth(tester, 'Damaged'), + greaterThan(chipWidth(tester, 'Sealed & intact') * 1.8), + ); + }); + + testWidgets('a blocked step gets a neutral bar, not a faded coloured one', ( + tester, + ) async { + await pump(tester, stopOf(type: 'combined', deliveryQty: 1, pickupQty: 2)); + + // Nothing has been filled in, so the step is blocked. + expect(find.text('1 parcel still to hand over'), findsOneWidget); + + // 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); + + // And the label stays readable: grey type on a neutral fill, not white + // dropped to 45% over a wash of itself. + // Named for where it goes, not for which step it is: the rider is on the + // hand-over leg, so the button announces the collection that follows it. + final label = tester.widget(find.text('Continue to collect')); + expect(label.style!.color, ColorConstants.secondaryText); + }); + + testWidgets('completing the step turns the bar a solid accent', ( + tester, + ) async { + await pump(tester, stopOf(type: 'delivery', deliveryQty: 1)); + + await tester.tap(find.text('Parcel 1 of 1')); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextField).first, '482916'); + await tester.pumpAndSettle(); + + // Photo is the last gate and the camera cannot be reached here, so the bar + // is still blocked — but on the name of what is missing, not the parcels. + expect(find.text('Take a proof photo'), findsOneWidget); + + // The parcel row and the OTP now report themselves done, which is the + // section-scoped tick: it speaks for its own step, never for the screen. + expect(find.text('Handed over'), findsOneWidget); + }); + + testWidgets('the app bar clears the status bar', (tester) async { + await pump(tester, stopOf(type: 'delivery', deliveryQty: 1)); + + // The title used to sit hard against the clock, which is where a system + // default puts it and nowhere near where the rest of the app puts a + // heading. `toolbarHeight` alone only moves it half as far as it adds. + final title = tester.getRect(find.text('Verify delivery')); + expect(title.top, greaterThan(24.0)); + }); +} diff --git a/test/stop_verify_layout_test.dart b/test/stop_verify_layout_test.dart new file mode 100644 index 0000000..58ee5f1 --- /dev/null +++ b/test/stop_verify_layout_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/pickups/stop_verify.dart'; + +/// The verification page is filled in at a doorstep, one-handed, often by +/// someone who has turned the system font up. It was rebuilt to remove the +/// nested boxes it used to be made of, and the pieces that replaced them — +/// a step counter in the app bar, a full-width parcel list, six OTP tiles, a +/// label/value review — are all row layouts with fixed-width parts, which is +/// exactly the shape that overflows when the text grows. +/// +/// A screenshot on the developer's own phone at 1.0x cannot catch that, so it +/// is measured here: every leg, on the narrowest phone the app supports, at +/// every text scale Android offers. +void main() { + Future pump( + WidgetTester tester, + Map stop, { + double width = 360, + double textScale = 1.0, + }) async { + tester.view.physicalSize = Size(width * 3, 780 * 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( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context) + .copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: StopVerificationPage(pickup: stop), + ), + ), + ); + 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', + }; + + for (final scale in const [1.0, 1.3, 1.5, 2.0]) { + testWidgets('the collect leg lays out at ${scale}x', (tester) async { + await pump(tester, stopOf('pickup', pickup: 3), textScale: scale); + expect(tester.takeException(), isNull); + }); + + testWidgets('the hand-over leg lays out at ${scale}x', (tester) async { + await pump(tester, stopOf('delivery', delivery: 4), textScale: scale); + expect(tester.takeException(), isNull); + }); + } + + 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.byType(SingleChildScrollView), + const Offset(0, -200), + ); + expect(find.text('PICKUP CODE'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + 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.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, + ); + }); +} diff --git a/test/stop_verify_review_test.dart b/test/stop_verify_review_test.dart new file mode 100644 index 0000000..32d4ccd --- /dev/null +++ b/test/stop_verify_review_test.dart @@ -0,0 +1,243 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +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/views/Dashboard/pickups/stop_verify.dart'; + +/// 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 +/// broke: the fact rows were `Row(crossAxisAlignment: stretch)` inside a +/// `SingleChildScrollView`, which asks the row to stretch to an infinite height. +/// Layout threw, the exception propagated up through the leg panel's Column, and +/// the last screen of the flow — the one the rider signs — showed no figures at +/// all. +/// +/// The gate is passable: [ImagePickerPlatform.instance] is the seam. Swapping in +/// a fake that returns a real file on disk lets the whole flow run to the review +/// and assert what the rider is actually shown. +class _FakeCamera extends ImagePickerPlatform { + final String path; + _FakeCamera(this.path); + + @override + Future getImageFromSource({ + required ImageSource source, + ImagePickerOptions options = const ImagePickerOptions(), + }) async => XFile(path); +} + +void main() { + setUpAll(() async { + final dir = await Directory.systemTemp.createTemp('miler_review'); + final png = File('${dir.path}/proof.png') + // A 1×1 PNG: Image.file needs bytes it can decode, not a real photo. + ..writeAsBytesSync(const [ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, // + 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, + 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 250, 207, 192, 240, + 31, 0, 5, 5, 2, 0, 141, 88, 175, 91, 0, 0, 0, 0, 73, 69, + 78, 68, 174, 66, 96, 130, + ]); + ImagePickerPlatform.instance = _FakeCamera(png.path); + }); + + Future pump( + WidgetTester tester, + Map stop, { + double width = 390, + double textScale = 1.0, + }) async { + tester.view.physicalSize = Size(width * 3, 844 * 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( + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(textScale)), + child: child!, + ), + home: StopVerificationPage(pickup: stop), + ), + ), + ); + await tester.pumpAndSettle(); + } + + /// Taps by visible label, scrolling it into view first — every one of these + /// controls sits below the fold on a phone. + Future tap(WidgetTester tester, String label) async { + final target = find.text(label).first; + await tester.ensureVisible(target); + await tester.pumpAndSettle(); + await tester.tap(target); + await tester.pumpAndSettle(); + } + + Future type(WidgetTester tester, String hint, String value) async { + await tester.enterText( + find.byWidgetPredicate( + (w) => w is TextField && w.decoration?.hintText == hint, + ), + value, + ); + await tester.pumpAndSettle(); + } + + Map stopOf({ + required String type, + 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', + }; + + testWidgets('a pickup stop shows every collected figure on the review', ( + tester, + ) async { + await pump(tester, stopOf(type: 'pickup', pickupQty: 3, cash: 2450)); + + await type(tester, 'Total weight in kg', '4.5'); + await tap(tester, 'Take photo'); + // The pickup code is six boxes over one hidden field, not a hinted one. + await tester.enterText(find.byType(TextField).last, '482156'); + await tester.pumpAndSettle(); + await tap(tester, 'Review'); + + // The heading is the screen's own voice, not another section title. + expect(find.text('Check before you confirm'), findsOneWidget); + + // Every figure the rider is signing for. Before the layout fix this whole + // block rendered as nothing. + expect(find.text('COLLECTED'), findsOneWidget); + expect(find.text('3 of 3'), findsOneWidget); + expect(find.text('4.5 kg'), findsOneWidget); + expect(find.text('Sealed & intact'), findsOneWidget); + expect(find.text('482156'), findsOneWidget); + + // The photo is shown, not asserted as a tick the rider cannot check. + expect(find.byType(Image), findsWidgets); + expect(find.text('Tap to enlarge'), findsOneWidget); + + // Money is flagged before the commit, not after it. + expect( + find.text('Collect ₹2450 from the customer on the next screen'), + findsOneWidget, + ); + expect(find.text('Confirm pickup'), findsOneWidget); + }); + + testWidgets('a combined stop reviews both legs separately', (tester) async { + await pump(tester, stopOf(type: 'combined', deliveryQty: 2, pickupQty: 3)); + + // Hand over. + await tap(tester, 'Parcel 1 of 2'); + await tap(tester, 'Parcel 2 of 2'); + await tap(tester, 'Security'); + await type(tester, "Person's name", 'Ramesh Kumar'); + await tester.enterText(find.byType(TextField).last, '482156'); + await tester.pumpAndSettle(); + await tap(tester, 'Take photo'); + await tap(tester, 'Continue to collect'); + + // Collect, with a damaged seal so the note takes its own wide row. + await tap(tester, 'Damaged'); + await type(tester, 'Total weight in kg', '12.75'); + await type( + tester, + 'e.g. corner of the box is crushed', + 'Corner of the second box is crushed', + ); + await tap(tester, 'Take photo'); + await tap(tester, 'Review'); + + // Two panels, each with its own facts and its own photo — the pairing is + // the whole reason both shots are taken. + expect(find.text('HANDED OVER'), findsOneWidget); + expect(find.text('2 of 2'), findsOneWidget); + expect(find.text('Ramesh Kumar · Security'), findsOneWidget); + + expect(find.text('COLLECTED'), findsOneWidget); + expect(find.text('3 of 3'), findsOneWidget); + expect(find.text('12.75 kg'), findsOneWidget); + expect(find.text('Damaged'), findsOneWidget); + expect(find.text('Corner of the second box is crushed'), findsOneWidget); + expect(find.text('Tap to enlarge'), findsNWidgets(2)); + + // Both legs are complete, and each says so on itself — on a combined stop + // the page-level verdict does not say which job is short. + expect(find.text('Complete'), findsNWidgets(2)); + + // No pickup code on a combined stop: the delivery OTP already proved the + // door, so it is never collected and must not be reviewed. + expect(find.text('Pickup code'), findsNothing); + }); + + testWidgets('tapping a proof photo opens it full frame', (tester) async { + await pump(tester, stopOf(type: 'pickup', pickupQty: 1)); + + await type(tester, 'Total weight in kg', '4.5'); + await tap(tester, 'Take photo'); + await tester.enterText(find.byType(TextField).last, '482156'); + await tester.pumpAndSettle(); + await tap(tester, 'Review'); + + await tap(tester, 'Tap to enlarge'); + expect(find.byType(InteractiveViewer), findsOneWidget); + }); + + // The review is a grid of fixed-width halves, which is the shape that + // overflows when the system font grows. Measured rather than eyeballed. + for (final scale in const [1.0, 1.3, 1.5, 2.0]) { + testWidgets('the review lays out at ${scale}x on a 360pt phone', ( + tester, + ) async { + await pump( + tester, + stopOf(type: 'combined', deliveryQty: 2, pickupQty: 3, cash: 2450), + width: 360, + textScale: scale, + ); + + await tap(tester, 'Parcel 1 of 2'); + await tap(tester, 'Parcel 2 of 2'); + await tap(tester, 'Security'); + await type(tester, "Person's name", 'Ramesh Kumar Subramaniam'); + await tester.enterText(find.byType(TextField).last, '482156'); + await tester.pumpAndSettle(); + await tap(tester, 'Take photo'); + await tap(tester, 'Continue to collect'); + + await tap(tester, 'Damaged'); + await type(tester, 'Total weight in kg', '12.75'); + await type( + tester, + 'e.g. corner of the box is crushed', + 'Corner of the second box is crushed on the underside', + ); + await tap(tester, 'Take photo'); + await tap(tester, 'Review'); + + expect(find.text('HANDED OVER'), findsOneWidget); + expect(find.text('COLLECTED'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/stop_verify_test.dart b/test/stop_verify_test.dart new file mode 100644 index 0000000..5ff7f71 --- /dev/null +++ b/test/stop_verify_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:miler/views/Dashboard/pickups/stop_verify.dart'; + +/// The verification page is the only place the app records what actually +/// happened at a door, so the thing worth testing is the gate: which legs it +/// runs for each stop kind, and that it refuses to advance until the proof for +/// the current leg exists. +void main() { + /// Pumps the page on a phone-shaped surface. The default 800×600 test window + /// makes ScreenUtil scale width up and height down at once, which is not a + /// shape any phone has — layouts that are fine on device fail there for the + /// wrong reason. + Future pumpStop(WidgetTester tester, Map stop) async { + tester.view.physicalSize = const Size(1170, 2532); // iPhone 13, @3x + 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: StopVerificationPage(pickup: stop)), + ), + ); + await tester.pumpAndSettle(); + } + + Map stopOf({ + required String type, + 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', + }; + + testWidgets('a pickup stop runs one collect leg', (tester) async { + await pumpStop(tester, stopOf(type: 'pickup', pickupQty: 3)); + + expect(find.text('Verify pickup'), findsOneWidget); + expect(find.text('Collect'), findsOneWidget); // stepper + expect(find.text('Confirm'), findsOneWidget); + expect(find.text('Hand over'), findsNothing); + + // The collect leg's own controls. + expect(find.text('PARCELS COLLECTED'), findsOneWidget); + expect(find.text('of 3 booked'), 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)); + + 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); + + // 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)); + + expect(find.text('Verify stop'), findsOneWidget); + // Three steps, delivery first — the rider walks up holding the outbound + // parcel, so he hands it over before taking anything back. + expect(find.text('Hand over'), findsOneWidget); + expect(find.text('Collect'), findsOneWidget); + expect(find.text('Confirm'), findsOneWidget); + + // Both loads are on the summary. + expect(find.text('Hand over 1'), findsOneWidget); + 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); + }); + + 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. + expect(find.text('1 parcel still to hand over'), findsOneWidget); + + await tester.tap(find.text('Parcel 1 of 1')); + await tester.pumpAndSettle(); + expect(find.text('Enter the OTP from the customer'), findsOneWidget); + + await tester.enterText(find.byType(TextField).first, '482156'); + await tester.pumpAndSettle(); + 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); + }); + + 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')); + await tester.pumpAndSettle(); + + expect(find.text("Person's name"), findsNothing); + await tester.tap(find.text('Security')); + await tester.pumpAndSettle(); + + expect(find.text("Person's name"), findsOneWidget); + expect(find.text('Enter the name of the person who took it'), + findsOneWidget); + }); + + testWidgets('a short count forces a written reason', (tester) async { + await pumpStop(tester, stopOf(type: 'pickup', pickupQty: 3)); + + // Pre-set to the booked count, so the common case needs no explanation. + expect(find.text('3'), findsOneWidget); + expect(find.text('WHAT HAPPENED?'), findsNothing); + + await tester.tap(find.byIcon(Icons.remove_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('1 fewer than the parcels booked'), 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); + await tester.tap(find.text('Damaged')); + await tester.pumpAndSettle(); + expect(find.text('WHAT HAPPENED?'), findsOneWidget); + }); + + testWidgets('COD is surfaced on the stop summary', (tester) async { + await pumpStop(tester, stopOf(type: 'pickup', pickupQty: 1, cash: 720)); + expect(find.text('₹720 to collect'), findsOneWidget); + }); +} diff --git a/test/text_scale_sweep_test.dart b/test/text_scale_sweep_test.dart new file mode 100644 index 0000000..0b4a736 --- /dev/null +++ b/test/text_scale_sweep_test.dart @@ -0,0 +1,105 @@ +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/views/Dashboard/activity/activity_page.dart'; +import 'package:miler/views/Dashboard/summary/summary.dart'; +import 'package:miler/views/offline/offline_banner.dart'; + +/// ── Riders turn the system font up ── +/// +/// An audit found the screens with layout tests (verify, collect payment, +/// profile, trip tabs) solid at 2×, and everything else unguarded: 443 `Text` +/// widgets against 109 `maxLines`, and exactly one file in 102 reading the text +/// scaler. +/// +/// The failure mode is not cosmetic. A `Row` with a fixed-width child overflows +/// into the black-and-yellow stripe bar, which on a customer's doorstep looks +/// like the app has crashed. Earnings was the worst of them — a 46sp hero +/// figure sharing a row with labels — which is why that figure is now on the +/// type scale. +/// +/// This sweeps the screens that had no coverage. It asserts no exception is +/// thrown, which is exactly what a `RenderFlex` overflow does. +void main() { + Future pump( + WidgetTester tester, + Widget page, { + double width = 360, + double scale = 1.0, + }) async { + tester.view.physicalSize = Size(width * 3, 780 * 3); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(390, 844), + minTextAdapt: true, + builder: (_, _) => MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: page, + ), + ), + ); + await tester.pump(const Duration(milliseconds: 400)); + } + + // The four scales Android offers, on the narrowest phone the app supports. + const scales = [1.0, 1.3, 1.5, 2.0]; + + group('Earnings', () { + for (final s in scales) { + testWidgets('lays out at ${s}x', (tester) async { + SharedPreferences.setMockInitialValues({'userId': 1}); + await pump(tester, const Summary(), scale: s); + expect(tester.takeException(), isNull); + }); + } + }); + + group('Activity', () { + for (final s in scales) { + testWidgets('lays out at ${s}x', (tester) async { + SharedPreferences.setMockInitialValues({'userId': 1}); + await pump(tester, const ActivityPage(), scale: s); + expect(tester.takeException(), isNull); + }); + } + }); + + // ── Sign In is covered by `auth_design_test.dart`, not here ── + // + // The screen calls `NotificationServce.initialize` in `initState`, which + // needs a platform channel a widget test has no way to answer. That is a test + // harness limitation rather than a layout finding: the auth *components* — + // the code row, the scaffold, the primary button, the inline error — are + // pumped and asserted directly in `auth_design_test.dart`, which is where the + // design surface actually lives. + + group('the offline strip', () { + for (final s in scales) { + testWidgets('lays out at ${s}x without covering the job', (tester) async { + await pump( + tester, + const OfflineScope( + offline: true, + child: Scaffold( + body: Center(child: Text('45, Lake View Apartments, Coimbatore')), + ), + ), + scale: s, + ); + expect(tester.takeException(), isNull); + // Still readable, still not on top of anything. + 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 new file mode 100644 index 0000000..1332d47 --- /dev/null +++ b/test/trip_brief_layout_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.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'; + +/// ───────────────────────────────────────────────────────────────────────── +/// The trip brief and the hub row have to survive every phone and every +/// accessibility text size. +/// +/// This exists because both of them broke in exactly that way while being +/// written. Putting "VIEW ROUTE" on the START · HUB row overflowed the row by +/// 6.2px at the design width — a `Row` of two natural-width children with a +/// `Spacer` between them has no give at all, and the failure only appears once +/// the two happen to be too wide together. The brief has the same shape of risk +/// in two places: four figures sharing one row, and a value like `₹1,23,456` +/// that is far wider than the `₹450` anyone tests with by hand. +/// +/// So this sweeps the four widths the app actually ships to against the text +/// scales Android exposes in Settings, and asserts only one thing: nothing +/// throws. Overflow in Flutter is a layout *exception*, so that single +/// assertion catches all of it. +/// ───────────────────────────────────────────────────────────────────────── +void main() { + /// 320 is the narrowest Android phone still in the field; 430 is a Pro Max. + const widths = [320.0, 360.0, 390.0, 430.0]; + + /// 2.0 is the top of Android's font-size slider. + const textScales = [1.0, 1.3, 1.5, 2.0]; + + /// A worst-case trip: six stops, and a cash figure with enough digits to + /// stress the narrowest of the four figure columns. + Trip bigTrip() => Trip.fromStops( + id: 't1', + stops: [ + for (var i = 0; i < 6; i++) + { + 'orderid': 'S$i', + 'type': 'pickup', + 'pickupcustomer': 'Sri Balaji Stores', + 'pickupaddress': '19, Thadagam Road, Venkitapuram, Coimbatore', + 'Quantity': '3', + 'collectionamt': '123456', + 'expected_pickup_time': DateTime.now().toIso8601String(), + }, + ], + ); + + for (final width in widths) { + for (final scale in textScales) { + testWidgets('lays out at ${width.toInt()}pt / ${scale}x', (tester) async { + tester.view.physicalSize = Size(width * 3, 844 * 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( + home: MediaQuery( + data: MediaQueryData(textScaler: TextScaler.linear(scale)), + child: Scaffold( + body: SingleChildScrollView( + child: TripCard( + trip: bigTrip(), + // Supplied, so the hub row carries VIEW ROUTE — the + // element that overflowed. + onViewRoute: () {}, + ), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + tester.takeException(), + isNull, + reason: 'collapsed brief overflowed at ${width}pt / ${scale}x', + ); + + await tester.tap(find.byType(TripBriefStrip)); + await tester.pumpAndSettle(); + expect( + tester.takeException(), + isNull, + reason: 'open brief overflowed at ${width}pt / ${scale}x', + ); + }); + } + } +} diff --git a/test/trip_empty_state_test.dart b/test/trip_empty_state_test.dart new file mode 100644 index 0000000..e1b98f5 --- /dev/null +++ b/test/trip_empty_state_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/home/trip_empty_state.dart'; + +/// An unfilled trip tab used to render a bordered white card with a grey +/// glyph in it — a piece of content that had arrived empty. It is the page +/// itself now, so the assertion that matters is that no card comes back: a +/// `Container` with a `Border` around the whole state is the thing being +/// removed, and a redesign that quietly reintroduces one should fail here. +void main() { + Future pump( + WidgetTester tester, { + required bool offline, + VoidCallback? onGoOnDuty, + }) async { + tester.view.physicalSize = const Size(1170, 2532); + 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: TripEmptyState( + tripLabel: 'Trip 2', + offline: offline, + onGoOnDuty: onGoOnDuty, + ), + ), + ), + ), + ); + // 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)); + } + + /// Any decoration carrying a border, anywhere in the state. + Finder borderedBoxes() => find.byWidgetPredicate((w) { + if (w is! Container) return false; + final d = w.decoration; + return d is BoxDecoration && d.border != null; + }); + + testWidgets('names the trip the tabs name, and draws no card', ( + tester, + ) async { + await pump(tester, offline: false); + + expect(find.text('Trip 2 not assigned yet'), findsOneWidget); + expect(borderedBoxes(), findsNothing); + }); + + testWidgets('says the app is still waiting rather than offering nothing', ( + 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. + 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 new file mode 100644 index 0000000..023a5e7 --- /dev/null +++ b/test/trip_progress_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_progress_rail.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 +/// the top of Bookings has to look the same either way. +void main() { + Widget app(List progress) { + return ScreenUtilInit( + designSize: const Size(390, 844), + builder: (_, _) => MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: TripProgressRail( + progress: progress, + slotLabel: '10:00 AM – 1:00 PM', + remaining: const Duration(minutes: 130), + ), + ), + ), + ), + ); + } + + Future pump(WidgetTester tester, List progress) async { + tester.view.physicalSize = const Size(1170, 2532); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(app(progress)); + await tester.pumpAndSettle(); + } + + 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, + for (var i = 0; i < skipped; i++) StopProgress.skipped, + for (var i = 0; i < pending; i++) StopProgress.pending, + ]; + } + + 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'); + }); + + testWidgets('never overflows, even on a long route', (tester) async { + await pump(tester, route(20, 5, 60)); // 86 stops + expect(tester.takeException(), isNull); + }); + + // Position, skips and time left are ONE line now, not two ends of a Row. + // They are one sentence about one trip, and splitting them left a gap in the + // middle wide enough to read as two unrelated facts. + testWidgets('reports position, skips and the count', (tester) async { + await pump(tester, route(4, 2, 11)); // 18 stops, current is #5 + expect(find.text('4 of 18 done'), findsOneWidget); + expect( + find.text('On stop 5 of 18 · 2 skipped · 2h 10m left, then hub'), + findsOneWidget, + ); + }); + + 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); + }); + + // The card, its border, its shadow and its padding are gone — the rail sits + // straight on the page above the stop list. This is the assertion that keeps + // it that way: a future "let's give it an edge" change has to come here first. + testWidgets('draws no box of its own', (tester) async { + await pump(tester, route(4, 2, 11)); + + final boxes = tester + .widgetList(find.descendant( + of: find.byType(TripProgressRail), + matching: find.byType(Container), + )) + .map((c) => c.decoration) + .whereType(); + + for (final d in boxes) { + expect(d.border, isNull, reason: 'no bordered container inside the rail'); + expect(d.boxShadow, anyOf(isNull, isEmpty), + reason: 'no elevation inside the rail'); + } + }); + + 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); + expect(find.text('Head back to the hub'), findsOneWidget); + }); + + // 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 { + 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 + // rides above the node rather than replacing its digit. + 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); + }); + + // 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); + + await pump(tester, route(2, 0, 2)); // on stop 3 of 5 + final from = tester.getCenter(scooter()).dx; + + // Not `pump()` — that settles, and the whole assertion is about the frames + // in between. + await tester.pumpWidget(app(route(3, 0, 1))); // stop 3 done, now on 4 + await tester.pump(const Duration(milliseconds: 120)); + final moving = tester.getCenter(scooter()).dx; + await tester.pumpAndSettle(); + final to = tester.getCenter(scooter()).dx; + + expect(to, greaterThan(from), reason: 'it advanced one stop'); + expect(moving, greaterThan(from)); + expect(moving, lessThan(to), reason: 'it travelled rather than jumped'); + }); + + 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); + + 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'); + }); + + // 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 { + await pump(tester, route(4, 0, 35)); // 40 stops + expect(find.byType(SingleChildScrollView), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets('the old per-stop hub labels are gone', (tester) async { + await pump(tester, route(2, 0, 4)); + expect(find.text('START'), findsNothing); + expect(find.text('RETURN'), findsNothing); + }); +} diff --git a/test/trip_tabs_pinned_test.dart b/test/trip_tabs_pinned_test.dart new file mode 100644 index 0000000..a48f062 --- /dev/null +++ b/test/trip_tabs_pinned_test.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:miler/views/Dashboard/home/trip.dart'; +import 'package:miler/views/Dashboard/home/trip_tabs.dart'; + +/// The Trip 1 / 2 / 3 bar is a **pinned** sliver on Home: it scrolls up with the +/// page, then holds at the top while the stops pass underneath, so switching +/// trips never costs a scroll back up. +/// +/// Two things here are worth a test. +/// +/// **The extent is computed, and computed height is the classic way to clip a +/// control.** A pinned sliver must declare a fixed height, and [TripTabs] +/// deliberately has none — its own comment records that a hard `54.h` overflowed +/// by a few pixels at large font settings. `extentFor` therefore derives the +/// height from the text scale and over-estimates on purpose. If that estimate is +/// ever short, the tabs are clipped inside the header, silently. So it is +/// measured here from 1.0× to 2.0×. +/// +/// **Pinning itself.** That the bar holds position while content scrolls under +/// it is the entire point of the change, and a one-word edit (`pinned: false`) +/// undoes it invisibly. +void main() { + // The view is forced to 390x844 logical below, so gestures must aim inside + // that box — x=400 would land outside the viewport and hit nothing. + const inViewport = Offset(195, 500); + + const aboveKey = ValueKey('above'); + 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'}, + ]), + ]; + + /// Pumps the real delegate inside a scroll view, at a given text scale, and + /// hands back the extent it reserved. + Future pump( + WidgetTester tester, { + double textScale = 1.0, + ValueChanged? onSelect, + }) async { + tester.view.physicalSize = const Size(1170, 2532); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + double? captured; + + 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( + body: Builder( + builder: (context) { + captured = TripTabsHeader.extentFor(context); + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Container( + key: aboveKey, + height: 300, + color: Colors.amber, + ), + ), + SliverPersistentHeader( + pinned: true, + delegate: TripTabsHeader( + extent: captured!, + trips: trips, + selectedIndex: 1, + onSelect: onSelect ?? (_) {}, + ), + ), + SliverToBoxAdapter( + child: Container( + key: belowKey, + height: 2000, + color: Colors.grey, + ), + ), + ], + ); + }, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return captured!; + } + + group('reserved extent fits the tabs', () { + // 2.0 is beyond Android's slider but within what a determined user can set, + // and it is the case the original 54.h bug was reported against. + for (final scale in [1.0, 1.15, 1.3, 1.5, 1.75, 2.0]) { + 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'); + + 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'); + + // 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'); + + // All three slots are still rendered and readable at this scale. + for (final label in ['Trip 1', 'Trip 2', 'Trip 3']) { + expect(find.text(label), findsOneWidget); + } + }); + } + }); + + group('pinning', () { + 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. + expect(atRest.top, greaterThan(200)); + + await tester.dragFrom(inViewport, const Offset(0, -600)); + 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'); + + // Scrolling further does not move it again. + await tester.dragFrom(inViewport, const Offset(0, -600)); + await tester.pumpAndSettle(); + expect(tester.getRect(find.byType(TripTabs)), pinned); + }); + + testWidgets('what sits above it does scroll away', (tester) async { + await pump(tester); + expect(find.byKey(aboveKey), findsOneWidget); + + await tester.dragFrom(inViewport, const Offset(0, -900)); + await tester.pumpAndSettle(); + + // The summary / shift / metrics block is meant to leave; only the control + // stays. A sliver that has left the viewport and its cache is unmounted. + expect(find.byKey(aboveKey), findsNothing); + expect(find.byType(TripTabs), findsOneWidget); + }); + + testWidgets('a tab is still tappable once pinned', (tester) async { + var picked = -1; + await pump(tester, onSelect: (i) => picked = i); + + await tester.dragFrom(inViewport, const Offset(0, -900)); + await tester.pumpAndSettle(); + + 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'); + }); + }); +} diff --git a/test/trip_test.dart b/test/trip_test.dart new file mode 100644 index 0000000..d21ed49 --- /dev/null +++ b/test/trip_test.dart @@ -0,0 +1,745 @@ +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, + int? step, + String type = 'pickup', + int qty = 1, + double? cash, + 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(), + }; + +void main() { + group('RouteMetricsHelper — distance units', () { + test('shows metres below 1 km, rounded to 10', () { + expect(RouteMetricsHelper.formatDistance(450), '450 m'); + expect(RouteMetricsHelper.formatDistance(447), '450 m'); + expect(RouteMetricsHelper.formatDistance(83), '80 m'); + expect(RouteMetricsHelper.formatDistance(999), '1000 m'); + }); + + test('shows kilometres with one decimal at and above 1 km', () { + expect(RouteMetricsHelper.formatDistance(1000), '1.0 km'); + expect(RouteMetricsHelper.formatDistance(3200), '3.2 km'); + }); + + test('drops the decimal past 100 km', () { + expect(RouteMetricsHelper.formatDistance(104400), '104 km'); + }); + + test('never fakes a zero', () { + expect(RouteMetricsHelper.formatDistance(null), '—'); + expect(RouteMetricsHelper.formatDistance(0), '—'); + expect(RouteMetricsHelper.formatDistanceAway(null), 'Distance unknown'); + }); + + test('rounds a tiny distance up to the 10 m floor', () { + expect(RouteMetricsHelper.formatDistance(3), '10 m'); + }); + }); + + group('RouteMetricsHelper — speed and ETA', () { + test('falls back to the urban average when speed is unusable', () { + 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); + // A GPS spike is a bad fix, not a fast rider. + expect(RouteMetricsHelper.usableSpeedMps(60), + RouteMetricsHelper.kDefaultSpeedMps); + }); + + test('uses a real riding speed when it is plausible', () { + expect(RouteMetricsHelper.usableSpeedMps(8.0), 8.0); + }); + + test('computes an arrival clock time', () { + final now = DateTime(2026, 7, 27, 16, 0); + // 1110 m at 5.55 m/s = 200 s ≈ 3 min. + final eta = RouteMetricsHelper.eta(1110, now: now)!; + expect(eta.difference(now).inSeconds, 200); + expect(RouteMetricsHelper.formatEta(1110, now: now), '4:03 PM'); + }); + + 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'); + }); + }); + + group('Trip — grouping into slots', () { + test('buckets stops into clock-aligned slot windows', () { + 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. + 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.stopCount, 1); + expect(trips.last.stopCount, 2); + }); + + 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'}, + {...stop(id: 'b', due: DateTime(2026, 7, 27, 20, 0)), 'tripid': 'T1'}, + ]); + + expect(trips.length, 1); + expect(trips.single.stopCount, 2); + }); + + test('stops with no time still land in a trip rather than vanishing', () { + final trips = Trip.groupIntoTrips([stop(id: 'a'), stop(id: 'b')]); + expect(trips.length, 1); + expect(trips.single.stopCount, 2); + expect(trips.single.slotLabel, "Today's route"); + }); + + test('trips come back earliest slot first', () { + final trips = Trip.groupIntoTrips([ + stop(id: 'late', due: DateTime(2026, 7, 27, 16, 0)), + stop(id: 'early', due: DateTime(2026, 7, 27, 10, 0)), + ]); + 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, + ); + expect(trips.single.slotLabel, '9:00 AM – 12:00 PM'); + }); + }); + + group('Trip — ordering', () { + test('respects the admin step sequence, never distance', () { + final ordered = Trip.sortStops([ + stop(id: 'c', step: 3), + stop(id: 'a', step: 1), + stop(id: 'b', step: 2), + ]); + expect([for (final s in ordered) s['orderid']], ['a', 'b', 'c']); + }); + + test('falls back to booked time when step is missing', () { + final ordered = Trip.sortStops([ + stop(id: 'later', due: DateTime(2026, 7, 27, 12, 0)), + stop(id: 'sooner', due: DateTime(2026, 7, 27, 10, 0)), + ]); + expect(ordered.first['orderid'], 'sooner'); + }); + + test('is stable when nothing distinguishes two stops', () { + final ordered = Trip.sortStops([stop(id: 'x'), stop(id: 'y')]); + expect([for (final s in ordered) s['orderid']], ['x', 'y']); + }); + }); + + group('Trip — duration estimates', () { + test('service time varies by stop kind', () { + final pickup = Trip.serviceTimeFor(stop(id: 'p', type: 'pickup')); + final delivery = Trip.serviceTimeFor(stop(id: 'd', type: 'delivery')); + final combined = Trip.serviceTimeFor(stop(id: 'c', type: 'combined')); + + // Handing over is quickest; doing both at one door is slowest. + expect(delivery < pickup, isTrue); + expect(pickup < combined, isTrue); + }); + + test('cash collection adds real minutes', () { + final free = Trip.serviceTimeFor(stop(id: 'a', type: 'pickup')); + final cod = Trip.serviceTimeFor(stop(id: 'b', type: 'pickup', cash: 250)); + expect(cod - free, const Duration(seconds: 90)); + }); + + test('extra parcels add handling time', () { + final one = Trip.serviceTimeFor(stop(id: 'a', type: 'pickup', qty: 1)); + final five = Trip.serviceTimeFor(stop(id: 'b', type: 'pickup', qty: 5)); + expect(five > one, isTrue); + }); + + test('total duration is ride time plus every doorstep minute', () { + final trip = Trip.fromStops( + id: 't', + stops: [ + stop(id: 'a', step: 1, lat: 11.03, lng: 76.96), + stop(id: 'b', step: 2, lat: 11.05, lng: 76.98), + ], + hubLat: 11.01, + hubLng: 76.95, + ); + + expect(trip.totalDuration, trip.travelDuration + trip.serviceDuration); + expect(trip.serviceDuration > Duration.zero, isTrue); + expect(trip.travelDuration > Duration.zero, isTrue); + }); + + test('round trip includes the return leg to the hub', () { + 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); + final withoutHub = Trip.fromStops(id: 'n', stops: stops); + + // hub → stop → hub must be about twice the one-way leg, and strictly + // longer than a chain with no hub at all. + expect(withHub.routeMeters, greaterThan(withoutHub.routeMeters)); + }); + + test('average stop duration divides service time across stops', () { + final trip = Trip.fromStops( + id: 't', + stops: [ + stop(id: 'a', step: 1, type: 'pickup'), + stop(id: 'b', step: 2, type: 'pickup'), + ], + ); + 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(formatTripDuration(const Duration(minutes: 100)), '1h 40m'); + }); + }); + + group('Trip — totals', () { + test('splits deliver and collect parcels and sums cash', () { + final trip = Trip.fromStops( + id: 't', + stops: [ + stop(id: 'a', step: 1, type: 'delivery', qty: 2), + stop(id: 'b', step: 2, type: 'pickup', qty: 3, cash: 250), + ], + ); + + expect(trip.deliverParcels, 2); + expect(trip.collectParcels, 3); + expect(trip.cashToCollect, 250); + expect(trip.orderIds, ['a', 'b']); + }); + + test('a combined stop counts on both sides', () { + final combined = { + ...stop(id: 'c', step: 1), + 'type': 'combined', + 'deliveryqty': '1', + 'pickupqty': '2', + }; + final trip = Trip.fromStops(id: 't', stops: [combined]); + + expect(stopKindOf(combined), StopKind.combined); + expect(trip.deliverParcels, 1); + expect(trip.collectParcels, 2); + expect(trip.totalParcels, 3); + }); + }); + + group('StopKind — three modes', () { + test('infers combined from a payload carrying both quantities', () { + final s = {'deliveryqty': '1', 'pickupqty': '2'}; + expect(stopKindOf(s), StopKind.combined); + }); + + test('combined runs the delivery leg first', () { + expect(StopKind.combined.legs, [StopLeg.delivery, StopLeg.pickup]); + expect(StopKind.pickup.legs, [StopLeg.pickup]); + expect(StopKind.delivery.legs, [StopLeg.delivery]); + }); + + test('isDelivery stays narrow so combined keeps its payment path', () { + // A combined stop still has a pickup leg that can carry COD, so it must + // NOT be treated as a prepaid delivery-only stop. + expect(StopKind.combined.isDelivery, isFalse); + expect(StopKind.combined.hasDelivery, isTrue); + expect(StopKind.combined.hasPickup, isTrue); + }); + + test('labels and accents differ per mode', () { + expect(StopKind.combined.badgeLabel, 'PICKUP & DELIVERY'); + expect(StopKind.combined.startCtaLabel, 'Start Combined Stop'); + expect(StopKind.pickup.accent == StopKind.delivery.accent, isFalse); + expect(StopKind.combined.accent == StopKind.pickup.accent, isFalse); + }); + + test('survives a wire round trip', () { + for (final k in StopKind.values) { + expect(stopKindFromWire(k.wireName), k); + } + expect(stopKindFromWire('nonsense'), StopKind.pickup); + }); + }); + + group('StopState — per-stop lifecycle', () { + final s1 = stop(id: 'a', step: 1); + final s2 = stop(id: 'b', step: 2); + + test('local accept/reject wins over a stale server status', () { + // The optimistic write must not be bounced back to pending by a slow + // refetch that still says "new". + expect( + stopStateOf(s1, acceptedIds: {'a'}, rejectedIds: const {}), + StopState.accepted, + ); + expect( + stopStateOf(s2, acceptedIds: const {}, rejectedIds: {'b'}), + StopState.rejected, + ); + }); + + test('reads the backend status when there is no local decision', () { + expect( + stopStateOf({...s1, 'orderstatus': 'picked'}, + acceptedIds: const {}, rejectedIds: const {}), + StopState.done, + ); + expect( + stopStateOf({...s1, 'orderstatus': 'ARRIVED'}, + acceptedIds: const {}, rejectedIds: const {}), + StopState.active, + ); + expect( + stopStateOf({...s1, 'orderstatus': 'skipped'}, + acceptedIds: const {}, rejectedIds: const {}), + StopState.skipped, + ); + expect( + stopStateOf(s1, acceptedIds: const {}, rejectedIds: const {}), + StopState.pending, + ); + }); + + test('classifies states for the UI', () { + expect(StopState.done.isResolved, isTrue); + // Rejected is resolved: the rider has nothing left to do with it, so it + // must not strand the trip below 100%. + expect(StopState.rejected.isResolved, isTrue); + expect(StopState.accepted.isResolved, isFalse); + expect(StopState.accepted.isCommitted, isTrue); + expect(StopState.pending.needsDecision, isTrue); + }); + }); + + group('Trip — completion and partial accepts', () { + final stops = [ + stop(id: 'a', step: 1), + stop(id: 'b', step: 2), + stop(id: 'c', step: 3), + stop(id: 'd', step: 4), + ]; + final trip = Trip.fromStops(id: 't', stops: stops); + + test('a fresh trip is 0% and all stops need a decision', () { + expect( + trip.completionPercent(acceptedIds: const {}, rejectedIds: const {}), + 0, + ); + expect( + trip.pendingOrderIds(acceptedIds: const {}, rejectedIds: const {}), + ['a', 'b', 'c', 'd'], + ); + }); + + test('accepting stops does NOT advance completion — work does', () { + // Accepting is a commitment, not progress. If accepting moved the ring + // the rider would see 100% before riding a metre. + expect( + trip.completionPercent( + acceptedIds: {'a', 'b', 'c', 'd'}, rejectedIds: const {}), + 0, + ); + }); + + test('the rider can take 1, 2 and 4 and decline 3', () { + final accepted = {'a', 'b', 'd'}; + const rejected = {'c'}; + + expect( + trip.pendingOrderIds(acceptedIds: accepted, rejectedIds: rejected), + isEmpty, + ); + expect( + trip.hasPendingStops(acceptedIds: accepted, rejectedIds: rejected), + isFalse, + ); + // The rejected stop counts as resolved → 1 of 4 = 25%. + expect( + trip.completionPercent(acceptedIds: accepted, rejectedIds: rejected), + 25, + ); + }); + + test('accept-all only targets stops still awaiting a decision', () { + 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], + ]); + expect( + worked.completionPercent( + acceptedIds: const {}, rejectedIds: const {}), + 50, + ); + expect( + worked.isFinished(acceptedIds: const {}, rejectedIds: const {}), + isFalse, + ); + }); + + 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], + ]); + expect( + worked.completionPercent(acceptedIds: const {}, rejectedIds: {'d'}), + 100, + ); + expect( + worked.isFinished(acceptedIds: const {}, rejectedIds: {'d'}), + isTrue, + ); + }); + + 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); + }); + + test('tab labels are 1-based', () { + expect(Trip.tabLabel(0), 'Trip 1'); + expect(Trip.tabLabel(2), 'Trip 3'); + }); + }); + + + group('StopState — decision precedence', () { + final s = stop(id: 'a', step: 1); + + test('a local un-reject beats a stale server "rejected"', () { + // The rider tapped "Accept instead"; the next poll still says rejected. + // 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 {}), + StopState.accepted, + ); + }); + + test('a local reject beats a stale server "accepted"', () { + expect( + stopStateOf({...s, 'orderstatus': 'accepted'}, + acceptedIds: const {}, rejectedIds: {'a'}), + StopState.rejected, + ); + }); + + test('collected work cannot be un-decided locally', () { + // You cannot un-collect a parcel. The terminal server state wins over + // any local tap. + expect( + stopStateOf({...s, 'orderstatus': 'picked'}, + acceptedIds: const {}, rejectedIds: {'a'}), + StopState.done, + ); + expect( + 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'}), + StopState.active, + ); + }); + + test('falls back to the server view with no local decision', () { + expect( + stopStateOf({...s, 'orderstatus': 'rejected'}, + acceptedIds: const {}, rejectedIds: const {}), + StopState.rejected, + ); + expect( + 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([ + stop(id: 'a', due: DateTime(2026, 7, 27, 10, 0)), + ]); + expect(trips.length, 1); + + // Trip 2 and Trip 3 exist as tabs; indices past the end are legitimate + // "not assigned yet" slots, not errors. + const maxInline = 3; + final tabCount = trips.length > maxInline ? trips.length : maxInline; + expect(tabCount, 3); + expect(2.clamp(0, tabCount - 1), 2); + }); + + test('an unusual day folds into three trips without losing a stop', () { + // Bucketing is time-based, so a stop booked well outside the others — or + // simply a long day — used to produce a fourth, fifth or eighth tab. + // "Trip 8" is not a thing the hub assigns. + final stops = [ + for (var h = 0; h < 24; h += 3) + stop(id: 'h$h', due: DateTime(2026, 7, 27, h, 30)), + ]; + final trips = Trip.groupIntoTrips(stops); + + 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'); + + 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 + // now holds, so the slot label stays honest. + expect(trips.last.stops.length, greaterThan(1)); + expect(trips.last.slotStart!.isBefore(trips.last.slotEnd!), isTrue); + }); + + test('a normal day is left exactly as it is', () { + final trips = Trip.groupIntoTrips([ + stop(id: 'a', due: DateTime(2026, 7, 27, 9, 30)), + stop(id: 'b', due: DateTime(2026, 7, 27, 13, 30)), + ]); + expect(trips.length, 2, reason: 'the cap only bites past three'); + }); + }); + + + 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'; + // 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 + }); + + test('strips a fully shared tail', () { + const a = '5/2, North Avenue, Vadavalli, Coimbatore, Tamil Nadu'; + const b = '78, Cross Cut Road, Gandhipuram, Coimbatore, Tamil Nadu'; + final tail = commonAddressTail(const [a, b]); + expect(tail, ['Coimbatore', 'Tamil Nadu']); + expect(stripAddressTail(a, tail), '5/2, North Avenue, Vadavalli'); + expect(stripAddressTail(b, tail), '78, Cross Cut Road, Gandhipuram'); + }); + + test('is a no-op when nothing is shared', () { + const a = '5/2, North Avenue, Vadavalli'; + const b = '78, Cross Cut Road, Chennai'; + expect(commonAddressTail(const [a, b]), isEmpty); + expect(stripAddressTail(a, const []), a); + }); + + test('is a no-op on a one-stop trip', () { + expect(commonAddressTail(const ['5/2, North Avenue, Coimbatore']), + isEmpty); + }); + + test('never strips an address down to nothing', () { + // Two identical addresses share everything; one component must survive. + const a = 'Coimbatore'; + final tail = commonAddressTail(const [a, a]); + expect(stripAddressTail(a, tail).isNotEmpty, isTrue); + }); + + test('ignores case when matching the tail', () { + const a = '5/2, North Avenue, COIMBATORE'; + const b = '78, Cross Cut Road, coimbatore'; + final tail = commonAddressTail(const [a, b]); + expect(tail.length, 1); + 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); + } + } + }); + }); + +} diff --git a/test_riderlog.py b/test_riderlog.py new file mode 100644 index 0000000..bd53e94 --- /dev/null +++ b/test_riderlog.py @@ -0,0 +1,66 @@ +import requests +import json +import socket +import urllib3 + +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + +HOSTNAME = "queue.workolik.com" +PHONE_IP = "125.21.240.67" # IP the phone resolved to + +# Show what IP the PC resolves to +pc_ip = socket.gethostbyname(HOSTNAME) +print(f"\nPC resolves {HOSTNAME} to: {pc_ip}") +print(f"Phone resolved {HOSTNAME} to: {PHONE_IP}") +print(f"Same IP? {pc_ip == PHONE_IP}") + +payload = { + "logid": None, + "userid": 1036, + "partnerid": 44, + "shiftid": 1, + "logdate": "2026-04-27 17:27:32", + "login": "17:27:32", + "latitude": "11.005080", + "longitude": "76.950849", + "raw_latitude": "11.005081", + "raw_longitude": "76.950849", + "velocity_lat": "0.0000", + "velocity_lng": "0.0000", + "speed": "0.00", + "heading": "0.00", + "onduty": 1, + "status": "idle", + "contactno": "8072675538", + "tenantid": 0, + "locationid": 0, + "applocationid": 1, + "userfcmtoken": "abc123", + "orderid": "1-20232131", + "username": "Vignesh S", + "firstname": "Vignesh", + "lastname": "S" +} + +headers = {"Content-Type": "application/json", "Accept": "application/json"} + +tests = [ + ("Via hostname (normal)", f"https://{HOSTNAME}/live/api/v2/partners/createriderlog"), + ("Direct to phone IP (125.21.240.67)", f"https://{PHONE_IP}/live/api/v2/partners/createriderlog"), +] + +for label, url in tests: + print(f"\n{'='*60}") + print(f"Test: {label}") + print(f"URL : {url}") + try: + h = dict(headers) + if PHONE_IP in url: + h["Host"] = HOSTNAME # tell Nginx the virtual host + r = requests.post(url, json=payload, headers=h, verify=False, timeout=10) + print(f"Status : {r.status_code}") + print(f"Response: {r.text[:300]}") + except Exception as e: + print(f"ERROR: {e}") + +print(f"\n{'='*60}\nDone.") diff --git a/upload_certificate.pem b/upload_certificate.pem new file mode 100644 index 0000000..95fadb9 --- /dev/null +++ b/upload_certificate.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDejCCAmKgAwIBAgIJAPGsje/uwReYMA0GCSqGSIb3DQEBCwUAMGoxCzAJBgNV +BAYTAjkxMRMwEQYDVQQIEwp0YW1pbCBuYWR1MRMwEQYDVQQHEwpDb2ltYmF0b3Jl +MQ8wDQYDVQQKEwZuZWFybGUxDzANBgNVBAsTBm5lYXJsZTEPMA0GA1UEAxMGbmVh +cmxlMCAXDTI1MTEwNjEyMjMyNFoYDzIwNTMwMzI0MTIyMzI0WjBqMQswCQYDVQQG +EwI5MTETMBEGA1UECBMKdGFtaWwgbmFkdTETMBEGA1UEBxMKQ29pbWJhdG9yZTEP +MA0GA1UEChMGbmVhcmxlMQ8wDQYDVQQLEwZuZWFybGUxDzANBgNVBAMTBm5lYXJs +ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMECSWjFk/pFoQDZQFnf +S9s/vp2QNGTtTrOZ6MqD1ZSN3D8dOVvs2RZy3flwq8nwe1jMFrQIzypLO+VQrwkO +U5KLg9d8aS616GrWcWgiNsfmBMxkPeTFuFy2I9ALdDlaSJFuxbGymYFsFhsNM+Lr +1xhIF/huZL176o4DaPpAIK9z4BWPWr1T/TIxMYBeIZgPzzPO+gjPLXV5DRs5dve9 +ObbkT/AW5/rFExEdqJjKiKjBk1MXwqAljKoEDhrpxKmRtLEOT9nXWuhTj3KHmGfm +NeEYNO8XBN5p5kdEG3iTK8hp62J0/w3lDO7Of4joNhqpUInjoE3uVdrxfC3p5Jrf +Pa0CAwEAAaMhMB8wHQYDVR0OBBYEFKFd61BMFF3EynKkbekHEnoxoX7IMA0GCSqG +SIb3DQEBCwUAA4IBAQCALZNYrG8UtLcTuPyqWpu9QXnVpulZgQY3UypwHu+coDHu +jerbkhSxdUaq6gTKuIwfUjI3b4tFapgrFcBkHt3/ovkEAO65tGFjQKoRP4LkvlkX +PR1wSVpBt9THU9Ws7oKb52Xeoo24CVc8b1nCKWqbfDA8fYuUULK5yxxB+KuPKrk1 +4W6WNoG0kwK/qFyvSHgFRFLGTpyJ9Dn0R+eN8otsBSYjtgvJ5p2i8rMDPjfXX1LE +DS3eMG6J5jWkGBrf9HMtak2HIqAhMbVGdcxK4bL76A368LFWLBQNzEs5rf9rC4B/ +9t3ynGMAxXWhgys7QiIY7YoPrJRmNfg0j7Ov4Hy+ +-----END CERTIFICATE----- diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..c82bab9 Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..7c0a9b8 Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..3511a9e Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..7c0a9b8 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..3511a9e Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/icons/doormile_icon.png b/web/icons/doormile_icon.png new file mode 100644 index 0000000..f643518 Binary files /dev/null and b/web/icons/doormile_icon.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..608fd0c --- /dev/null +++ b/web/index.html @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + doormile + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..5a75295 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "doormile", + "short_name": "doormile", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} \ No newline at end of file diff --git a/web/splash/img/dark-1x.png b/web/splash/img/dark-1x.png new file mode 100644 index 0000000..0d0edc0 Binary files /dev/null and b/web/splash/img/dark-1x.png differ diff --git a/web/splash/img/dark-2x.png b/web/splash/img/dark-2x.png new file mode 100644 index 0000000..3511a9e Binary files /dev/null and b/web/splash/img/dark-2x.png differ diff --git a/web/splash/img/dark-3x.png b/web/splash/img/dark-3x.png new file mode 100644 index 0000000..2af1d3f Binary files /dev/null and b/web/splash/img/dark-3x.png differ diff --git a/web/splash/img/dark-4x.png b/web/splash/img/dark-4x.png new file mode 100644 index 0000000..45bc617 Binary files /dev/null and b/web/splash/img/dark-4x.png differ diff --git a/web/splash/img/light-1x.png b/web/splash/img/light-1x.png new file mode 100644 index 0000000..0d0edc0 Binary files /dev/null and b/web/splash/img/light-1x.png differ diff --git a/web/splash/img/light-2x.png b/web/splash/img/light-2x.png new file mode 100644 index 0000000..3511a9e Binary files /dev/null and b/web/splash/img/light-2x.png differ diff --git a/web/splash/img/light-3x.png b/web/splash/img/light-3x.png new file mode 100644 index 0000000..2af1d3f Binary files /dev/null and b/web/splash/img/light-3x.png differ diff --git a/web/splash/img/light-4x.png b/web/splash/img/light-4x.png new file mode 100644 index 0000000..45bc617 Binary files /dev/null and b/web/splash/img/light-4x.png differ diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..ec4098a --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..0ea1880 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(doormile LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "doormile") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..efb62eb --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..fbb6c69 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,38 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); + BatteryPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("BatteryPlusWindowsPlugin")); + ConnectivityPlusWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + FlutterTtsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterTtsPlugin")); + GeolocatorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GeolocatorWindows")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..fb1fda1 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,33 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows + battery_plus + connectivity_plus + file_selector_windows + firebase_core + flutter_tts + geolocator_windows + permission_handler_windows + url_launcher_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..2041a04 --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..e97759b --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "Doormile" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "doormile" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "doormile.exe" "\0" + VALUE "ProductName", "Doormile" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..c819cb0 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..28c2383 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..fb13221 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"Doormile", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..ddc7f3e --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..4b962bb --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..259d85b --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3f0e05c --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..b5ba2a0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..49b847f --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_