From c65d78878ab2d43df4250fdfe162d2ed19999923 Mon Sep 17 00:00:00 2001 From: Suriya Date: Wed, 12 Aug 2026 10:50:45 +0530 Subject: [PATCH] new changes maps --- .gitignore | 100 +- API_ENDPOINTS.md | 451 ++ android/app/build.gradle | 62 +- android/app/src/main/AndroidManifest.xml | 8 - deploy/osrm/README.md | 81 + deploy/osrm/docker-compose.yml | 40 + docs/MAPS.md | 99 + docs/PORTFOLIO.md | 620 +++ lib/background/backgroundservice.dart | 1938 ++++---- lib/controllers/deliveries_controller.dart | 4024 ++++++++--------- lib/controllers/support_ticket.dart | 416 +- lib/services/maps/map_config.dart | 44 + lib/services/maps/map_marker_icons.dart | 242 + lib/services/maps/nearle_map.dart | 374 ++ lib/services/maps/routing_service.dart | 332 ++ .../Dashboard/deliveries/deliveries.dart | 5 +- lib/views/Dashboard/deliveries/map.dart | 202 +- lib/views/Dashboard/deliveries/multi_map.dart | 291 +- lib/views/Dashboard/deliveries/nav.dart | 77 +- lib/views/Dashboard/home/homepage.dart | 114 +- .../profile/informations/help_center.dart | 2 +- pubspec.lock | 100 +- pubspec.yaml | 5 +- test/maps_smoke_test.dart | 175 + tool/compare_route_distances.py | 132 + 25 files changed, 6120 insertions(+), 3814 deletions(-) create mode 100644 API_ENDPOINTS.md create mode 100644 deploy/osrm/README.md create mode 100644 deploy/osrm/docker-compose.yml create mode 100644 docs/MAPS.md create mode 100644 docs/PORTFOLIO.md create mode 100644 lib/services/maps/map_config.dart create mode 100644 lib/services/maps/map_marker_icons.dart create mode 100644 lib/services/maps/nearle_map.dart create mode 100644 lib/services/maps/routing_service.dart create mode 100644 test/maps_smoke_test.dart create mode 100755 tool/compare_route_distances.py diff --git a/.gitignore b/.gitignore index d0ae102..373eb50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,49 +1,51 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.build/ -.buildlog/ -.history -.svn/ -.swiftpm/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ -/coverage/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release - -# Claude AI settings -.claude/ - +# 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 + +# Claude AI settings +.claude/ + +# Self-hosted OSRM graph data (large, regenerated from OSM) +/deploy/osrm/data/ diff --git a/API_ENDPOINTS.md b/API_ENDPOINTS.md new file mode 100644 index 0000000..fecfc81 --- /dev/null +++ b/API_ENDPOINTS.md @@ -0,0 +1,451 @@ +# Nearle Rider — API Reference + +Generated from a source sweep of `lib/`. Every endpoint the app talks to, with +request payloads for all write operations. + +## Base hosts + +| Host | Used for | Notes | +|---|---|---| +| `jupiter.nearle.app` | all **reads** | env segment from `ApiConstants.mainRoute` (`live`) / `mainDev` (`dev`) | +| `queue.workolik.com` | all **writes** (status updates + logs) | hardcoded `live`, DNS pinned to `66.116.225.226`, TLS cert validation bypassed (`lib/providers/deliverylog/deliverylog_provider.dart:20`) | +| `msg.lionsms.com` | OTP SMS | | +| `maps.googleapis.com` | route distance | | +| `sgp1.digitaloceanspaces.com` → `images.nearle.app` | proof / ticket images (Minio SDK, not REST) | | +| `66.116.225.226:1883` | MQTT telemetry | | + +**No auth header on any call.** Every request sends only +`Content-Type: application/json` / `Accept: application/json`. Identity is +carried as a `userid` field in the query string or body. + +Endpoint constants live in `lib/views/helpers/constants/apiconstants.dart`. + +--- + +## 1. Auth + +### POST `https://jupiter.nearle.app/live/api/v2/users/rider/login` +`lib/providers/auth/auth_provider.dart:19` + +```json +{ + "contactno": "9876543210", + "devicetype": "android", + "configid": 1, + "deviceid": "", + "userfcmtoken": "", + "pin": 1234 +} +``` +`pin` is only included when non-null. + +Response `details` is persisted to SharedPreferences: `userid`, `shiftid`, +`logid`, `riderid`, `partnerid`, `configid`, `logseconds`, `locationid`, +`tenantid`, `applocationid`, `userfcmtoken`, `firstname`, `lastname`, +`username`, `onduty`, `fuelcharge`, `firstmilecharge`, `starttime`, `endtime`, +`deliveryradius` (default 100 m). + +### PUT `https://jupiter.nearle.app/live/api/v2/users/update` +Set / change MPIN — `lib/providers/auth/auth_provider.dart:186` + +```json +{ "userid": 1242, "pin": 1234 } +``` + +### GET `https://jupiter.nearle.app/live/api/v1/platform/getsmsprovider?templatetypeid=1` +Fetches `templateid` + SMS `content` template. Cached 1 hour. +`lib/controllers/auth.dart:367` + +### GET `https://msg.lionsms.com/api/smsapi` +`lib/controllers/auth.dart:407` + +| Param | Value | +|---|---| +| `key` | `e57f5c9679af26077be1a7eadabb1b2a` | +| `route` | `7` | +| `sender` | `NEARLE` | +| `number` | `+91` | +| `templateid` | from `getsmsprovider` | +| `sms` | URL-encoded body with OTP substituted for `{#var#}` | + +The OTP is **generated on-device** and stored in `SharedPreferences('lastOtp')`. + +--- + +## 2. Deliveries — reads (all GET) + +| Endpoint | Query | Caller | +|---|---|---| +| `…/{env}/api/v2/deliveries/getdeliveryqueues` | `userid, fromdate, todate, t` (+ optional `orderstatus`) | `lib/providers/delivery/delivery_provider.dart:34` | +| `…/{env}/api/v1/deliveries/getdeliveries` | `userid, fromdate, todate, t` | `lib/background/backgroundservice.dart:68` | +| `…/{env}/api/v2/deliveries/getdeliveries` | `userid, fromdate, todate, t` | hardcoded in `homepage.dart:1074`, `cartpage.dart:79`, `deliveries.dart:563` | +| `…/{env}/api/v3/deliveries/getdeliveries` | `userid, fromdate, todate, t` | picked orders — `delivery_provider.dart:92` | + +- `fromdate` / `todate` are always **today** in `YYYY-MM-DD`. +- `t` is a cache-buster (epoch ms). +- Response shape: `{ "code": 200, "status": true, "message": "Success", "details": [ … ] }`. + Readers fall back to `data` then the raw body. + +--- + +## 3. Delivery status updates + +### PUT `https://queue.workolik.com/live/api/v1/deliveries/updatedelivery` + +One endpoint, eight payload shapes. All from `lib/controllers/deliveries_controller.dart`. + +Most variants share this GPS block: +`riderslat`, `riderslon`, `raw_latitude`, `raw_longitude`, `velocity_lat`, +`velocity_lng`, `speed`, `heading`. + +#### accepted — `:695` +```json +{ + "deliveryid": 0, + "orderheaderid": 0, + "orderstatus": "accepted", + "acceptedtime": "YYYY-MM-DD HH:MM:SS", + "riderslat": "", "riderslon": "", + "raw_latitude": "", "raw_longitude": "", + "velocity_lat": "", "velocity_lng": "", + "speed": "", "heading": "", + "deliverylat": "", "deliverylong": "", + "actualkms": "", + "deliveryamt": 0.0, + "notes": "" +} +``` + +#### active (start navigation) — `:1317` +Same as accepted, plus: +```json +{ + "orderstatus": "active", + "activetime": "YYYY-MM-DD HH:MM:SS", + "starttime": "YYYY-MM-DD HH:MM:SS", + "activelat": "", "activelon": "" +} +``` +Aborts if rider coordinates resolve to `0`. + +#### arrived — `:1429` +Same as accepted, with `"orderstatus": "arrived"` and +`"arrivaltime": "YYYY-MM-DD HH:MM:SS"`. Geofence-checked against the pickup +location before sending. + +#### picked — `:280` +No `raw_*` / velocity block on this one. +```json +{ + "deliveryid": 0, + "orderheaderid": 0, + "pickuplocationid": 0, + "orderstatus": "picked", + "pickuptime": "YYYY-MM-DD HH:MM:SS", + "riderslat": "", "riderslon": "", + "deliverylat": "", "deliverylong": "", + "actualkms": "0.00", + "deliveryamt": 0.0, + "deliverytype": "", + "address": "", "city": "", "state": "", "suburb": "", "postcode": "", + "notes": "", + "pickupimage": "", + "proofimage": "" +} +``` +`actualkms` is computed rider→pickup when not supplied. Geofence-checked. + +#### delivered — `:1229` +```json +{ + "deliveryid": 0, + "orderheaderid": 0, + "deliverylocationid": 0, + "orderstatus": "delivered", + "deliveredtime": "YYYY-MM-DD HH:MM:SS", + "deliverytime": "YYYY-MM-DD HH:MM:SS", + "smsdelivery": 0, + "riderslat": "", "riderslon": "", + "raw_latitude": "", "raw_longitude": "", + "velocity_lat": "", "velocity_lng": "", + "speed": "", "heading": "", + "pickuplat": "", "pickuplong": "", + "deliverylat": "", "deliverylong": "", + "riderkms": "", + "ridercharges": 0, + "deliveryamt": 0, + "collectionamt": 0, + "collectedamt": 0, + "collectionstatus": "", + "ridertime": 0, + "notes": "", + "wasskipped": false, + "bonuspts": 0, + "dropimage": "" +} +``` +Returns `false` without sending if rider **or** drop coordinates are `0` or +out of range (`|lat| > 90`, `|lng| > 180`). + +#### skipped — `:1758` +```json +{ + "deliveryid": 0, + "orderheaderid": 0, + "orderstatus": "skipped", + "skippedtime": "YYYY-MM-DD HH:MM:SS", + "riderslat": "", "riderslon": "", + "deliverylat": "", "deliverylong": "", + "actualkms": "", + "deliveryamt": 0.0, + "notes": "", + "riderkms": "0.00" +} +``` +`riderkms` is added only when the computed distance is > 0 (Google Directions, +falling back to straight-line). + +#### rejected — `:1477` +Status block only, no timestamp: `"orderstatus": "rejected"` plus the shared +GPS block, `deliverylat`/`deliverylong`, `actualkms`, `deliveryamt`, `notes`. + +#### cancelled — `:1813` +```json +{ + "deliveryid": 0, + "orderheaderid": 0, + "orderstatus": "cancelled", + "canceltime": "YYYY-MM-DD HH:MM:SS", + "riderslat": "", "riderslon": "", + "raw_latitude": "", "raw_longitude": "", + "velocity_lat": "", "velocity_lng": "", + "speed": "", "heading": "", + "deliverylat": "", "deliverylong": "", + "riderkms": "0.00", + "ridercharges": 0, + "deliveryamt": 0.0, + "notes": "" +} +``` + +--- + +## 4. Delivery log (background heartbeat) + +### POST `https://queue.workolik.com/live/api/v2/deliveries/createdeliverylog` + +**The body is wrapped in an array** — `json.encode([data])` +(`lib/providers/deliverylog/deliverylog_provider.dart:73`). + +```json +[ + { + "logid": 0, + "tenantid": 0, + "partnerid": 0, + "locationid": 0, + "orderheaderid": 0, + "deliveryid": 0, + "userid": 1242, + "orderid": "ORD123", + "orderstatus": "active", + "logdate": "YYYY-MM-DD HH:MM:SS", + "latitude": "", "longitude": "", + "raw_latitude": "", "raw_longitude": "", + "velocity_lat": "", "velocity_lng": "", + "speed": "", "heading": "", + "riderkms": 0.0, + "logstatus": 0 + } +] +``` + +Base fields come from `_createPayload` (`backgroundservice.dart:236`); GPS, +`logdate`, `riderkms` and `logstatus` are merged in `_postDeliveryLog` (`:180`). +Posted roughly every 30 s per active delivery. 10 s timeout; failures are +queued in SharedPreferences and retried. + +--- + +## 5. Rider log + +### GET `…/{env}/api/v1/partners/getriderlog?userid=` +Response `details` is cached as `riderlog_template` and reused as the base for +every subsequent create-log payload. + +### GET `…/{env}/api/v1/partners/getridercount?userid=` + +### POST `https://queue.workolik.com/live/api/v2/partners/createriderlog` +Periodic on-duty ping — `lib/controllers/riderlog.dart:289`, +`lib/background/foreground_service.dart:289` + +```json +{ + "logid": 0, + "userid": 1242, + "partnerid": 0, + "shiftid": 0, + "logdate": "YYYY-MM-DD HH:MM:SS", + "login": "HH:MM:SS", + "latitude": "", "longitude": "", + "raw_latitude": "", "raw_longitude": "", + "velocity_lat": "", "velocity_lng": "", + "speed": "", "heading": "", + "onduty": 1, + "status": "active", + "contactno": "", + "username": "", + "firstname": "", + "lastname": "", + "tenantid": 0, + "locationid": 0, + "applocationid": 0, + "userfcmtoken": "", + "orderid": "" +} +``` + +- Built by spreading the cached `riderlog_template`, then overriding time, GPS + and identity fields. +- `status` is `"active"` when deliveries are live, otherwise `"idle"`. +- `firstname` / `lastname` keys are **removed** when empty; `username` is always + sent even if blank. +- Interval = `logseconds` from login, forced to **30 s** while deliveries are + active. 3 retries with 1 s backoff, then the offline queue (capped at 50). + +### PUT `…/{env}/api/v1/partners/updateriderlog` +Duty toggle — `lib/controllers/riderlog.dart:520`, model at +`lib/Models/riders/riders_models.dart:275` + +```json +{ + "userid": 1242, + "logstatus": null, + "Logout": null, + "workhours": null, + "shorthours": null, + "latitude": "", + "longitude": "", + "onduty": 1 +} +``` +Note the capitalized `Logout` key (and `Login` in `RiderLogin.toJson`). + +--- + +## 6. Breaks + +### POST `https://queue.workolik.com/live/api/v2/partners/createbreaklog` +`lib/controllers/riderlog.dart:925` + +```json +{ + "breakid": 427, + "logid": 0, + "breakdate": "YYYY-MM-DD HH:MM:SS", + "userid": 1242, + "partnerid": 0, + "shiftid": 0, + "breakstart": "HH:MM:SS", + "breakend": "", + "breakhours": 0.0, + "latitude": "", + "longitude": "" +} +``` +`breakid` is a **client-generated random 3-digit** value (100–999), replaced by +the server id when one comes back. + +Also emitted by the auto-shift-end flow at `backgroundservice.dart:765`. + +### PUT `https://queue.workolik.com/live/api/v2/partners/updatebreaklog` +`lib/controllers/riderlog.dart:196` + +Same shape, with `breakend` filled and `breakhours` as fractional hours +(`duration.inSeconds / 3600`). + +--- + +## 7. Stats & rewards (GET) + +| Endpoint | Source | +|---|---| +| `…/live/api/v2/partners/getdeliverystats?userid=` | `lib/providers/summary/summary.dart:10` | +| `…/live/api/v1/partners/getriderweeklykms?userid=` | `lib/views/Dashboard/summary/summary.dart:318` | +| `…/live/api/v1/utils/getuserbonussummary/?userid=` | `lib/controllers/rewards_controller.dart:16` | + +--- + +## 8. Support tickets + +### GET `…/live/api/v1/partners/getridersupport/?userid=` + +### POST `…/live/api/v1/partners/createridersupport/` +`lib/controllers/support_ticket.dart:186` + +```json +{ + "userid": 1242, + "category": "", + "priority": "", + "subject": "", + "issue": "", + "image": "https://images.nearle.app/support/ticket-1234-1242.jpg" +} +``` + +Images are uploaded directly to DigitalOcean Spaces with the Minio SDK +(bucket `nearle`, folder `support`, region `sgp1`) — not through the API. The +returned CDN URL is what goes in `image`. + +--- + +## 9. External services + +- **GET** `https://maps.googleapis.com/maps/api/directions/json?origin=&destination=&key=&units=metric&mode=driving` + — road distance for km calculation, 5 s timeout (`deliveries_controller.dart:1115`) +- `launchUrl` only (no HTTP from the app): + - `https://www.google.com/maps/dir/?api=1&destination=,` (`nav.dart:444`) + - `https://nearle.in/terms`, `https://nearle.in/privacy`, `https://nearle.in/faq` + - `https://play.google.com/store/apps/details?id=com.nearle.partner` + - `https://apps.apple.com/us/app/nearle/id1596895375ls=1` + +### MQTT +`66.116.225.226:1883`, user `admin` (`lib/views/helpers/constants/mqtt_constants.dart`) + +| Topic | Payload | +|---|---| +| `nearle/riders/{riderId}/status` | `Online` / `Offline` / `Idle` / `Active` | +| `nearle/riders/{riderId}/profile` | `userid, username, firstname, lastname, contactno, userfcmtoken, app_version, device_info` | +| `nearle/riders/{riderId}/location` | `userid, orderid, lat, lng, raw_lat, raw_lng, speed, heading, velocity_lat, velocity_lng, timestamp` | +| `nearle/riders/{riderId}/telemetry` | battery, charging, speed, connection, accuracy | +| `nearle/riders/{riderId}/logs` | `rider_periodic_log`, `delivery_picked`, `critical_battery`, alerts | + +--- + +## Known issues found during the sweep + +1. **`lib/controllers/riderkm.dart:11` builds a broken URL.** `baseUrl` is + already `…/v1/partners/getriderweeklykms`, and the code appends + `/getriderweeklykms?userid=` → `…/getriderweeklykms/getriderweeklykms?userid=`. + The summary *view* calls it correctly, so this controller path is likely dead + or silently failing. +2. **`lib/controllers/support_ticket.dart:66` hardcodes `userId = 1242`** in + `fetchTickets()` — every rider sees user 1242's ticket list. +3. **Secrets shipped in the APK**: DO Spaces access key + secret + (`support_ticket.dart`, `deliveries_controller.dart`), LionSMS API key, + MQTT `admin` / `Package@321#`, Google Maps key. +4. **OTP is generated and verified on-device**, stored in + `SharedPreferences('lastOtp')` — trivially bypassable. +5. **TLS verification disabled** for `queue.workolik.com` plus a hardcoded IP + pin (`66.116.225.226`). If that VPS IP changes, every write endpoint breaks + at once. +6. **`backgroundservice.dart:845` POSTs to `updateriderlog`** while every other + caller PUTs it. If the server is method-strict, auto-shift-end never marks + the rider offline. +7. `updateDeliveryDev`, `createRiderLogDev`, `createBreakRiderLogDev`, + `updateBreakRiderLogDev` and `createDeliveryLogDev` all point at **live** — + there is no working dev environment for writes. +8. `lib/providers/support/support_ticket.dart` is a dead duplicate of the + controller and references an `…/partners/uploadimage/` endpoint nothing else + uses. diff --git a/android/app/build.gradle b/android/app/build.gradle index c2fcaaf..e32c69d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -22,14 +22,13 @@ if (keystorePropertiesFile.exists()) { keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) } -def googleMapsApiKey = localProperties.getProperty("GOOGLE_MAPS_API_KEY") ?: "" - android { namespace = "com.nearle.partner" compileSdk = 36 - // Correct NDK version - ndkVersion "27.0.12077973" + // Must be >= the highest NDK required by any plugin (maplibre_gl needs + // 28.1.13356709). NDK releases are backward compatible, so the highest wins. + ndkVersion "28.1.13356709" packagingOptions { jniLibs { @@ -58,7 +57,6 @@ android { versionName flutterVersionName manifestPlaceholders = [ - GOOGLE_MAPS_API_KEY: googleMapsApiKey, applicationName: "android.app.Application" ] @@ -120,3 +118,57 @@ tasks.register("copyNotificationSounds", Copy) { tasks.named("preBuild").configure { dependsOn("copyNotificationSounds") } + +// --------------------------------------------------------------------------- +// Release artifact naming: nearlepartnerv.aab / .apk +// +// The file is RENAMED rather than copied, and any previous nearlepartner* +// artifact is removed first, so exactly one bundle ever sits in the output +// directory. That matters: when two .aab files are present, Flutter's +// "✓ Built ..." line can name the wrong one, which is an easy way to upload a +// stale build to the Play Store. +// --------------------------------------------------------------------------- +// `versionName` is passed in explicitly: script-level `def` variables such as +// flutterVersionName are not in scope inside a `def` method in Groovy. +def renameReleaseArtifact(File outputDir, String defaultName, String extension, String versionName) { + def source = new File(outputDir, defaultName) + if (!source.exists()) { + return + } + + outputDir.listFiles({ File f -> + f.name.startsWith("nearlepartner") && f.name.endsWith(extension) + } as FileFilter)?.each { it.delete() } + + def stamp = new Date().format("ddMMyy") + def target = new File(outputDir, "nearlepartner${stamp}v${versionName}${extension}") + if (source.renameTo(target)) { + println "Release artifact renamed -> ${target.name}" + } else { + println "WARNING: could not rename ${source.name}; leaving it in place" + } +} + +def releaseArtifactVersion = flutterVersionName + +tasks.matching { it.name == "bundleRelease" }.configureEach { + doLast { + renameReleaseArtifact( + layout.buildDirectory.dir("outputs/bundle/release").get().asFile, + "app-release.aab", + ".aab", + releaseArtifactVersion + ) + } +} + +tasks.matching { it.name == "assembleRelease" }.configureEach { + doLast { + renameReleaseArtifact( + layout.buildDirectory.dir("outputs/apk/release").get().asFile, + "app-release.apk", + ".apk", + releaseArtifactVersion + ) + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 008b489..c92235e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,9 +3,6 @@ - - - @@ -37,11 +34,6 @@ android:icon="@mipmap/ic_launcher" android:usesCleartextTraffic="true"> - - - + osrm-routed + --algorithm mld + --max-table-size 8000 + /data/${OSRM_REGION:-southern-zone-latest}.osrm + volumes: + - ./data:/data:ro + ports: + - "5000:5000" + healthcheck: + # Coimbatore -> a nearby point; fails loudly if the graph did not load. + test: + - CMD-SHELL + - >- + wget -qO- "http://localhost:5000/route/v1/driving/76.9558,11.0168;76.9776,10.9984" + | grep -q '"code":"Ok"' || exit 1 + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + deploy: + resources: + limits: + # Southern-zone MLD needs well under this; raise for all-India. + memory: 4G diff --git a/docs/MAPS.md b/docs/MAPS.md new file mode 100644 index 0000000..bf8acc3 --- /dev/null +++ b/docs/MAPS.md @@ -0,0 +1,99 @@ +# Maps & routing + +Fully open source, no API key, no account, no card. + +| Concern | Technology | Cost | +| --- | --- | --- | +| Rendering | [MapLibre GL](https://maplibre.org) (BSD) | free | +| Tiles | [OpenFreeMap](https://openfreemap.org) — OSM data, MIT | free, no key, no limits | +| Routing | [OSRM](https://project-osrm.org) (BSD) | free, self-hostable | + +There is no dependency on Google Maps: no Maps SDK, no Directions API, no +`com.google.android.geo.API_KEY` in the manifest, and no credential of any kind +shipped in the app. + +## Why + +Google's mobile Maps *SDK* is free and unlimited — the bill came from the +**Directions API** at $5 per 1,000 requests above a 10,000/month free tier. Two +things made it worse: the multi-stop screen issued one Directions call *per +route segment*, and the API key was hardcoded in five places (including +`AndroidManifest.xml`), unrestricted because the routing calls went out from the +device as web-service requests. + +Commercial alternatives were considered and rejected because they all want an +account and a payment method. The open-source stack has neither. + +## Configuration + +Nothing is required to run the app — the defaults work out of the box: + +```sh +flutter run +``` + +Both endpoints are overridable at build time, so production can point at +self-hosted infrastructure without a code change: + +```sh +flutter build apk \ + --dart-define=OSRM_BASE_URL=https://osrm.your-domain.in \ + --dart-define=MAP_STYLE_URL=https://tiles.your-domain.in/styles/liberty +``` + +> **Before production**, stand up your own OSRM. The default +> (`routing.openstreetmap.de`) is the FOSSGIS community server — free and +> keyless, but donation-funded with a fair-use policy that a delivery fleet +> would abuse. See [`deploy/osrm/README.md`](../deploy/osrm/README.md); the +> Coimbatore region needs only a 0.52 GB extract. +> +> Tiles do **not** need self-hosting — OpenFreeMap's public instance has no key +> and no request limits, and permits commercial use under MIT with attribution +> (which MapLibre renders automatically). + +## Layout + +| File | Role | +| --- | --- | +| `lib/services/maps/map_config.dart` | Tile style + routing endpoints | +| `lib/services/maps/routing_service.dart` | OSRM client, polyline decoding, response cache | +| `lib/services/maps/map_marker_icons.dart` | Canvas-drawn marker bitmaps (MapLibre has no built-in pins) | +| `lib/services/maps/nearle_map.dart` | The `NearleMap` widget — declarative markers/polylines over MapLibre | + +Provider choice is confined to the first two files. The four map screens talk +only to `NearleMap` and `RoutingService`, so swapping backends again would not +touch UI code. + +### OSRM notes + +- Coordinates go in the URL path as **`lon,lat`** — the reverse of most APIs. +- `overview=full&geometries=polyline` returns an encoded polyline at + **precision 5**. OSRM's `polyline6` option is precision 6; pass + `precision: 6` to `decodePolyline` if you ever switch. +- `_parse()` is deliberately tolerant — plain-number *or* `{"value": n}` + distances, several geometry key names, GeoJSON `LineString` — so a proxy or an + OSRM-compatible alternative (Valhalla, GraphHopper) drops in without changes. + +## Cost controls + +These mattered when routing was billed and still cut latency and load: + +- **One call per route, not per leg.** The multi-stop screen passes intermediate + stops as additional coordinates in a single OSRM request. It previously issued + one request per consecutive pair, so an N-stop route cost N calls every time + the screen opened. +- **Response cache.** `RoutingService` caches by origin/destination rounded to + ~11 m for 30 minutes, so repeated quotes for the same delivery reuse one call. +- **Rendering makes no routing calls at all.** + +## Known gaps vs. the old Google implementation + +- **Info windows.** MapLibre has no info-window concept. Marker taps still fire + (`NearleMapMarker.onTap` drives the customer sheet on the multi-stop screen), + but the old tap-to-show-title bubbles are gone. +- **Traffic and 3D buildings.** `trafficEnabled` / `buildingsEnabled` on the + navigation screen had no MapLibre equivalent and were dropped. OSRM has no + live-traffic model either — ETAs are free-flow estimates. +- **Turn-by-turn navigation is unchanged.** It still hands off to whatever + navigation app is installed via a `google.navigation:` / maps URL intent. That + is an OS intent, not an API — no key, no SDK, no billing. diff --git a/docs/PORTFOLIO.md b/docs/PORTFOLIO.md new file mode 100644 index 0000000..f2665db --- /dev/null +++ b/docs/PORTFOLIO.md @@ -0,0 +1,620 @@ +# Nearle Rider (`com.nearle.partner`) — Portfolio Technical Summary + +A production Android delivery-rider app for a last-mile logistics operation in +Coimbatore, India. ~30,000 lines of Dart plus native Kotlin. It's the app a +delivery rider has open on the handlebar for an entire shift. + +There are really **six** distinct systems in here, not one app. Taken in order of +engineering weight. + +--- + +## 1. The GPS Pipeline — Trusted Distance Measurement + +### What it is + +Riders are paid by the kilometre. The formula lives in `deliveries_controller.dart`: + +``` +ridercharges = distance_travelled_km × fuelcharge_rate +``` + +That single line is why this subsystem exists. Consumer phone GPS is noisy: it +drifts several metres while stationary, jumps hundreds of metres when switching +between cell-tower and satellite fixes, degrades under power-saving mode, and can +be trivially faked with a mock-location app. Every one of those failure modes is +*money* — noise inflates payouts, jumps inflate payouts spectacularly, and +spoofing is outright fraud. + +So the problem isn't "track the rider." It's: **produce a distance number that is +defensible enough to pay against, from a sensor that lies.** + +### How it's built + +``` + Android GPS (bestForNavigation, distanceFilter: 0) + │ raw Position stream, ~1 Hz + ▼ + ┌──────────────────────────────────────────┐ + │ Gate 1: isMocked? → drop │ anti-fraud + │ Gate 2: accuracy > 50 m? → drop │ quality + │ Gate 3: <3 s since last? → drop │ rate limit + │ Gate 4: _isProcessing? → drop │ re-entrancy mutex + └──────────────────────────────────────────┘ + ▼ + ┌──────────────────────────────────────────┐ + │ 4-D Kalman filter │ + │ state = [lat, lng, v_lat, v_lng] │ + │ predict(dt) → update(measurement) │ + └──────────────────────────────────────────┘ + │ smoothed (lat, lng) + ├──────────────────────────────► MQTT nearle/riders/{id}/location + │ (QoS 0 — fire and forget) + ▼ + ┌──────────────────────────────────────────┐ + │ Per-delivery odometer, in SharedPrefs │ + │ Haversine(anchor → now) │ + │ Gate 5: implied speed > 120 km/h │ + │ AND jump > 200 m → move anchor,│ + │ DON'T count the metres │ + │ Gate 6: delta < 5 m → skip, DON'T move │ + │ anchor (noise floor) │ + │ else: cumulativeKm += delta │ + └──────────────────────────────────────────┘ +``` + +### The approach, step by step + +**The Kalman filter** (`lib/utils/kalman_filter.dart`) is hand-implemented — no +linear-algebra dependency, just nested `List>` and explicitly +unrolled matrix helpers (`_multiply2x4_4x2`, `_inverse2x2`, and so on). State is +4-dimensional: position *and* velocity in each axis. The measurement matrix `H` +is 2×4 because GPS only observes position; velocity is inferred from how position +evolves. Measurement noise `R` is set to `1e-5` degrees² (~1 m), encoding "GPS is +accurate to roughly 5–10 m." `dt` is fed in live from wall-clock deltas, so the +filter behaves correctly whether samples arrive 3 seconds apart (foreground) or 30 +seconds apart (background). + +The payload sends **both** the filtered and the raw coordinates (`lat`/`lng` +alongside `raw_lat`/`raw_lng`). That's a good call: dispatch sees the smooth +track, but the unfiltered truth is preserved server-side for audit or for +re-deriving with different parameters later. + +**The two gates are asymmetric on purpose, and this is the cleverest bit.** When +GPS jumps implausibly far, the code moves the anchor point but does *not* add the +distance — the phantom kilometres are discarded. When movement is *below* 5 m, it +does the opposite: it skips *without* moving the anchor. If it moved the anchor on +tiny deltas, a phone sitting still would accumulate GPS jitter forever and +silently print money. Getting both directions right requires actually reasoning +about the failure mode rather than just clamping values. + +The jump threshold is also velocity-based rather than fixed: +`max_realistic = elapsed_seconds × 33.3 m/s` (120 km/h). A 200 m step in 3 seconds +is fraud; the same 200 m step across a 60-second background interval is a normal +motorcycle. A fixed distance threshold would have to choose one and be wrong for +the other. + +### The tricky case: two isolates, one odometer + +Flutter's foreground service runs in a **separate Dart isolate** with its own +memory. So there are two independent copies of this pipeline — +`LiveTrackingService` (main isolate, 3 s cadence) and `_BackgroundRiderLog` +(service isolate, 30 s cadence) — and both want to increment the same odometer. +Naively, every metre gets counted twice and every rider gets paid double. + +The coordination is a **heartbeat in shared storage**: + +``` +main isolate ──writes──► live_tracking_last_update_ms ──reads──► service isolate + │ + if (now − heartbeat < 10 s) │ + → main is alive, stand down + else │ + → main is dormant, take over +``` + +The main isolate stamps a timestamp on every update. The background isolate reads +it and accumulates *only* if the stamp is stale by 10 seconds — meaning the app is +backgrounded, screen-off, or the OS has frozen the main isolate. Handover in both +directions is automatic and requires no signalling. Since `SharedPreferences` +caches in memory per isolate, the background side calls `prefs.reload()` before +reading to force a disk re-read. + +*This is a lease/lock pattern implemented over the only shared medium two Dart +isolates reliably have.* It's the single most non-obvious piece of engineering in +the codebase. + +### Trade-offs + +- **`SharedPreferences` as the IPC channel.** It's key–value storage, not a + database — no transactions, no atomic increments. Two writers to the same key + could theoretically interleave. The 10-second lease makes that window vanishingly + small in practice, and it buys zero extra dependencies plus survival across + process death. A proper answer would be SQLite with a write-ahead log; this is + the pragmatic one. +- **Cumulative distance beats point-to-point.** At delivery confirmation the code + prefers the tracked odometer over any recomputed origin→destination distance, + precisely because the odometer reflects the path actually ridden — detours, + one-ways, wrong turns and all. +- Reading the code honestly: the process-noise matrix `Q` has entries at `[2][0]` + and `[3][1]` that make it non-symmetric, which is unusual for a covariance + matrix. It doesn't destabilise the filter at these magnitudes, but it looks like + a transcription slip rather than a deliberate choice. + +--- + +## 2. The Maps & Routing Migration — Removing Google Entirely + +### What it is + +The app originally used Google Maps SDK for rendering and the Google Directions +API for road routes. It was billing painfully. The rebuild replaced the entire +stack with open-source components — **no API key of any kind ships in the app +anymore.** + +### The diagnosis (this is the interesting part) + +The obvious assumption — "map rendering is expensive" — is wrong. Google's mobile +Maps *SDK* is free and unlimited. The bill came from **Directions**, at $5 per +1,000 calls above a 10,000/month free tier. Two things amplified it: + +1. The multi-stop screen issued **one Directions request per consecutive pair of + stops**. Opening a 6-stop route cost 5 billable requests — *every time the + screen opened*, and riders open it constantly. +2. The API key was hardcoded in five places including `AndroidManifest.xml`. + Because `flutter_polyline_points` called the Directions **web service** from the + device, the key could not carry an Android-app restriction — Google only + supports that for SDK calls. Anyone who unzipped the APK had an unrestricted, + billable key. + +So the fix that actually saves money is replacing *routing*, not rendering. That +reframing is what made the migration tractable. + +### The replacement stack + +| Concern | Was | Now | Cost | +| --- | --- | --- | --- | +| Rendering | Google Maps SDK | **MapLibre GL** (BSD) | free | +| Tiles | Google | **OpenFreeMap** (OSM data, MIT) | free, keyless, no limits | +| Routing | Directions API | **OSRM** (BSD, self-hostable) | free | +| Turn-by-turn | — | OS intent handoff (`google.navigation:`) | free — it's an intent, not an API | + +### Architecture + +``` + 4 map screens (map, multi_map, nav, homepage) + │ + ┌─────────────────┴─────────────────┐ + ▼ ▼ + NearleMap widget RoutingService + (declarative markers/lines) (OSRM client + cache) + │ │ + ├── MapMarkerIcons ──┐ │ + │ (Canvas → PNG) │ │ + ▼ ▼ ▼ + maplibre_gl MapConfig ──── endpoint URLs + │ (String.fromEnvironment) + ▼ ▼ + OpenFreeMap tiles OSRM /route/v1/driving/… +``` + +The provider choice is confined to **two files** (`map_config.dart`, +`routing_service.dart`). No UI file names a vendor. Swapping backends again +wouldn't touch a screen. + +### How each hard part was solved + +**One call, not N.** OSRM accepts every stop as a coordinate in a single request +path: `lon,lat;lon,lat;lon,lat`. The multi-stop screen now passes intermediate +stops as `waypoints` in one call. N requests → 1. + +**Response caching.** `RoutingService` keys its cache on coordinates rounded to 4 +decimal places (~11 m), with a 30-minute TTL and a 120-entry cap evicted +oldest-first. Repeated quotes for the same delivery reuse one response. + +**A deliberately tolerant parser.** `_parse()` accepts distances as plain numbers +*or* as `{"value": n}` objects, falls back from route-level totals to summing legs, +and hunts for geometry under `geometry` / `overview_polyline` / `polyline`, nested +`{"points": …}`, GeoJSON `coordinates`, and finally by stitching per-step +geometries. This is defensive work that lets a proxy, a self-hosted build, or an +OSRM-compatible alternative (Valhalla, GraphHopper) drop in without a code change. + +**The polyline codec.** Both Google and OSRM encode geometry using Google's +Encoded Polyline Algorithm — a variable-length, zigzag-encoded, base-64-offset +delta format. It's implemented from scratch (`decodePolyline`) with the precision +parameterised, because OSRM's default `geometries=polyline` is precision 5 while +its `polyline6` option is precision 6. Get that wrong and routes render as a tiny +smudge near the equator. + +**The lon,lat trap.** OSRM takes coordinates as `lon,lat` in the URL path — +reversed from nearly every other geo API. Swapping them silently returns routes in +the wrong hemisphere rather than erroring. The smoke test asserts route endpoints +land within 0.02° of the requested points specifically to catch this. + +**Markers had to be redrawn from nothing.** MapLibre has no `BitmapDescriptor` +equivalent. `map_marker_icons.dart` draws teardrop pins, rider dots, and numbered +step circles directly onto a `dart:ui` Canvas and rasterises them to PNG bytes, +cached by a composite key so identical icons register once. + +**Diffing instead of clear-and-re-add.** On the navigation screen the rider marker +moves every few metres. Tearing down and re-adding every annotation would flicker +visibly. `_NearleMapState` keeps `Map` / `Map` +registries and calls `updateSymbol`/`updateLine` on changed items only. Two +supporting details make this work: + +- `NearleMapMarker`'s `==` **deliberately excludes `onTap`**. A new closure on + every rebuild would otherwise mark every marker dirty. Taps are instead resolved + by looking up the ID against the *current* widget's marker list, so the handler + still closes over fresh state. +- Native annotation calls aren't re-entrant, so `_syncing`/`_syncQueued` flags + coalesce overlapping syncs into one trailing run. + +**Degenerate camera bounds.** Fitting the camera to a single point (or two points +on an exact horizontal line) makes MapLibre's native fit routine fail. `fitBounds` +nudges any span below `0.0015°` (~165 m) outward around its midpoint first. + +### Trade-offs accepted + +- **No info windows.** MapLibre has no equivalent. Marker taps still fire and + drive the customer sheet; tap-to-show-title bubbles are gone. +- **No traffic layer, no 3D buildings.** No MapLibre equivalent, and OSRM has no + live-traffic model either — ETAs are free-flow estimates. +- **The public FOSSGIS OSRM server is the default.** It's donation-funded with a + fair-use policy a delivery fleet would abuse. `deploy/osrm/` ships a Docker + Compose stack and preprocessing guide for self-hosting; the whole southern-India + extract is only 0.52 GB. The endpoint is overridable via + `--dart-define=OSRM_BASE_URL=…` with no code change. + +### Verification + +The migration was checked at the **artifact** level, not the build-log level — the +release AAB was unzipped and inspected: `libmaplibre.so` present for 3 ABIs, +`com/google/android/gms/maps` references **0**, `org/maplibre` references 341. +`test/maps_smoke_test.dart` runs 8 tests including live hits against both +endpoints, validating the polyline decoder against Google's canonical published +example and asserting no `api_key=` appears in the fetched style document. + +--- + +## 3. Delivery Lifecycle & Compensation Engine + +### What it is + +The state machine a delivery moves through, and the money that falls out of it. + +``` + accepted → active → arrived → picked → delivered + │ │ │ + ├─ rejected └─ (or skipped / cancelled) +``` + +Each transition is a method on `DeliveriesController` and each does far more than +flip a status. + +### The delivery-confirmation path + +`updateDeliveredStatus()` is ~400 lines and reads like a checklist of everything +that can go wrong in the field: + +**Step 1 — Geofence gate.** Compute Haversine distance from the rider to the drop +point. If it exceeds a server-configured radius, show the exact distance and +required radius, and **refuse the update**. This is the anti-fraud gate that stops +"delivered" being tapped from home. + +**Step 2 — Distance resolution, four-tier cascade.** This is a +graceful-degradation ladder, best source first: + +1. Cumulative GPS odometer (§1) — the actual path ridden +2. Caller-supplied `actualKms` +3. OSRM road distance from a recorded origin — tried against the saved start + location, then the provided pickup, then the cached pickup, then the drop point +4. Straight-line Haversine — if routing is unreachable + +Then a floor: if everything failed, use 0.01 km. Never zero, never null. The +reasoning is visible in the code — a null propagating into `ridercharges` means an +unpaid delivery and an angry rider, which is worse than a slightly wrong number. + +**Step 3 — Bonus points.** An ETA deadline is stamped at `eta_endtime_{orderId}` +when the delivery goes active. Delivered at or before it → bonus points equal to +`round(distance_km)`. + +**Step 4 — Skip penalty.** A 3-hour rolling window tracks skip count. If the rider +is over the limit, bonus points are **forfeited to zero** — the base per-km pay is +untouched. Penalising the incentive rather than the wage is a deliberate, and +fairly humane, design choice. + +**Step 5 — Coordinate validation.** Reject `0`, reject `|lat| > 90`, reject +`|lng| > 180`, and abort the whole update rather than post garbage. Compare with +the geofence check, which *fails open* on missing coordinates (warn and proceed) — +the two policies are opposite, and correctly so. A geofence failure shouldn't +strand a rider who genuinely delivered; a malformed coordinate write would corrupt +the record permanently. + +**Step 6 — Money.** `ridercharges = km × fuelcharge`, rounded to 2 decimals to +match a `DECIMAL(10,2)` column server-side. + +### Route sequencing + +Stop ordering (`step`) is assigned **server-side** — the app doesn't solve a +travelling-salesman problem. What it does handle is *display* sequencing: +`preservedStepNumbers` keeps numbering stable as deliveries complete or get +skipped, so a stop the rider has been calling "number 3" doesn't silently renumber +itself mid-shift. Orders arriving without a step get synthesised display numbers +appended after the numbered ones. Small feature, real usability value. + +--- + +## 4. Background Execution & Shift Automation + +### What it is + +Android aggressively kills background work — Doze mode, app standby, and +OEM-specific battery managers (particularly aggressive on the Chinese-brand +handsets common in this market). But a rider's shift must be logged, and their +shift must end on time, whether or not the app is alive. + +This system is a **layered survival strategy**: each layer covers the failure of +the one above. + +``` + Layer 1 — App foregrounded + LiveTrackingService, 3 s cadence, full fidelity + + Layer 2 — App backgrounded + flutter_foreground_task, 30 s repeat, persistent notification, + foregroundServiceType="dataSync|location", wake + WiFi locks + + Layer 3 — App process killed + Android AlarmManager, setExactAndAllowWhileIdle (pierces Doze) + → ShiftEndReceiver.kt, pure Kotlin, no Flutter engine + → reads Flutter's SharedPreferences directly ("flutter." prefix) + → raw HttpURLConnection POST, under a 60 s PARTIAL_WAKE_LOCK + + Layer 4 — Phone rebooted / alarm lost + On next app launch, main.dart replays the shift-end check + and creates the break log retroactively +``` + +Layer 3 is the interesting one. The receiver deliberately **does not** boot the +Flutter engine — starting a Dart VM from a broadcast receiver is slow and +unreliable. Instead it reaches into `FlutterSharedPreferences` (Flutter prefixes +every key with `flutter.`, which the Kotlin reads verbatim), hand-builds the JSON +payload as a string, and POSTs it with `HttpURLConnection`. The break log is +created correctly even from a cold-killed app. + +### Overnight shift arithmetic + +Shifts are stored as bare `HH:mm:ss` strings with no date, which makes "has the +shift ended?" genuinely ambiguous. The logic compares start and end as fractional +hours; if `start > end`, the shift crosses midnight, and "over" means *within the +gap between end and start*, not simply "after end": + +``` + 22:00 start, 06:00 end + now 23:00 → after start → ACTIVE + now 05:00 → before end → ACTIVE (yesterday's shift) + now 10:00 → after end AND before start → OVER +``` + +That check is implemented three times — Dart foreground, Dart background isolate, +and Kotlin — because each layer may be the only one running. Triplicated logic is a +maintenance liability, and it's the right call anyway: a single shared +implementation isn't reachable from a receiver that can't start Dart. + +--- + +## 5. Offline Resilience + +Riders lose signal in basements, lifts, and rural stretches constantly. Every +write path has a queue behind it. + +``` + POST attempt (8–10 s timeout) + │ + success ──► persist "last known good" anchor, done + │ + failure ──► append {orderId, payload, timestamp} to a + SharedPreferences string-list queue + │ + ▼ + on next successful cycle / app start / reconnect: + drain queue, retry each, keep failures, rewrite remainder +``` + +Two independent queues exist — `offline_delivery_logs` and `offline_rider_logs` — +each with an `_isFlushing` re-entrancy guard so overlapping timers can't +double-post. Requests carry explicit timeouts throughout, so a hung socket +degrades to a queued write instead of freezing a UI thread. + +Payloads are persisted per order (`delivery_payload_{orderId}`) so a delivery in +progress survives a process kill and resumes with the same identity rather than +starting a fresh log. + +--- + +## 6. Real-Time Telemetry Bus (MQTT) + +Location, status, and device health stream to dispatch over MQTT rather than HTTP +polling. + +``` + nearle/riders/{riderId}/status retained + Last Will (QoS 1) + nearle/riders/{riderId}/location QoS 0, ~every 3 s + nearle/riders/{riderId}/profile retained (QoS 1) + nearle/riders/{riderId}/telemetry every 5 min (QoS 1) + nearle/riders/{riderId}/logs/{event} (QoS 1) + nearle/riders/{riderId}/alerts (QoS 1) +``` + +The QoS choices are thought through, not defaults. Location is **QoS 0** — a +dropped fix is replaced by a fresher one 3 seconds later, so guaranteed delivery +would be pure overhead. Status is **QoS 1 and retained**, so a dispatcher opening +the console sees current state immediately rather than waiting for the next +publish. + +**Last Will and Testament** is the sharp detail: the client registers an `Offline` +message with the broker at connect time. If the rider's phone dies, loses signal, +or force-quits, the *broker* publishes that message on their behalf. Dispatch +learns the rider is gone without any cooperation from the dead client. + +Client IDs are salted per isolate startup (`rider_{id}_{base36 timestamp}`) — +otherwise the main and background isolates would connect with identical IDs and +MQTT brokers evict the older session, causing the two to kick each other offline in +a loop. + +Alerts fire on device conditions dispatch actually cares about: location services +disabled, battery under 15% and not charging, GPS accuracy worse than 30 m. + +--- + +## Supporting Features + +Smaller but real: + +- **Auth** — phone precheck → OTP (with `sms_autofill`) → MPIN, with server-side + PIN verification and session refresh +- **Picture-in-Picture** — native Android PiP via `MethodChannel`, so an ETA + countdown stays visible while the rider is in Google Maps +- **Proximity alerts** — within 50 m of the drop, fire a local notification plus a + bundled audio clip, falling back to TTS if playback fails. A per-delivery flag + ensures once only +- **Proof-of-delivery upload** — camera capture → DigitalOcean Spaces + (S3-compatible, via the `minio` client) → CDN URL returned to the API +- **Push** — FCM with per-rider selectable alert sounds; the Android notification + channel is *recreated* under a new ID when the sound changes, because channel + settings are immutable after creation +- **Summary & rewards** — `fl_chart` dashboards, weekly km, scratch-card rewards + (`scratcher` + `confetti`) +- **Support tickets**, FAQ, saved addresses, forced-update gate against the Play + Store version + +--- + +## The Stack + +**Language / runtime** +- Dart (SDK ^3.9.2) · Flutter · Kotlin (native Android layer) + +**State & architecture** +- GetX (`get`) — DI, reactive state, routing · Provider · MVC-ish split: + `views/` → `controllers/` → `providers/` → `Models/` + +**Geo** +- `maplibre_gl` (rendering) · OpenFreeMap (OSM vector tiles) · OSRM (routing) · + `geolocator` · `geocoding` · custom Kalman filter · custom polyline codec + +**Background & lifecycle** +- `flutter_foreground_task` · Android AlarmManager (native) · `wakelock_plus` · + `battery_plus` · `connectivity_plus` + +**Networking & messaging** +- `http` · `mqtt_client` · Firebase Core + Messaging (FCM) · `minio` + (S3-compatible object storage) + +**Storage** +- `shared_preferences` (state, queues, cross-isolate coordination) · + `path_provider` · DigitalOcean Spaces + CDN (media) + +**UI** +- `flutter_screenutil` · `fl_chart` · `lottie` · `shimmer` · `confetti` · + `scratcher` · `flutter_slidable` · `webview_flutter` · Proxima Nova + +**Device** +- `permission_handler` · `image_picker` · `flutter_tts` · `audioplayers` · + `vibration` · `sms_autofill` · `url_launcher` · `floating` (PiP) + +**Build** +- Gradle (Groovy) · compileSdk 36 · NDK 28.1.13356709 · R8 minify + resource + shrink · arm64-v8a only · custom release-artifact naming task + +**Backend (consumed, not in this repo)** +- REST across two hosts — a primary API and a separate write/queue host · MQTT + broker on a VPS · versioned endpoints (v1/v2/v3) · `dev`/`live` switched by one + constant + +--- + +## What's Genuinely Hard Here + +Ranked by how much real thought each needed. + +**1. Cross-isolate odometer coordination.** Two Dart isolates with no shared +memory, both holding a GPS stream, both able to increment the same paid counter, +either able to be frozen by the OS without notice. The heartbeat-lease over +`SharedPreferences` solves it in about 15 lines — but arriving at that solution +means understanding Flutter's isolate model, Android's process lifecycle, and why +the obvious fixes (a flag, a mutex, a port) all fail across a process boundary. + +**2. Trustworthy distance from an untrustworthy sensor.** Not "apply a Kalman +filter" — the filter is the easy half. The hard half is the *asymmetry*: discard +distance but move the anchor on a jump; discard the sample but hold the anchor on +jitter. Both directions have to be right or the odometer drifts in one direction, +and that direction is money. The velocity-scaled jump threshold, so the same guard +works at 3 s and 60 s cadences, is the same kind of thinking. + +**3. Correctly diagnosing the Maps bill.** The valuable engineering was the +analysis, not the code. "Maps SDK is free; Directions is billed; the multi-stop +screen was making N calls per screen-open; the key couldn't be restricted because +the calls were web-service calls" — that chain of reasoning is what turned an +unbounded vendor migration into a well-scoped one that landed. Plenty of teams +would have swapped rendering engines and wondered why the bill barely moved. + +**4. Four-layer background survival.** Anyone can start a foreground service. +Making shift-end fire from a *killed process* means: knowing +`setExactAndAllowWhileIdle` is the only AlarmManager call that pierces Doze; +knowing a broadcast receiver shouldn't boot a Flutter engine; knowing Flutter's +`SharedPreferences` keys carry a `flutter.` prefix readable from Kotlin; and +knowing you need a `PARTIAL_WAKE_LOCK` or the device sleeps mid-HTTP-request. Each +of those is a scar. + +**5. Flicker-free declarative annotations over an imperative native map.** +MapLibre's controller is imperative — `addSymbol`, `updateSymbol`, `removeSymbol` +— and non-re-entrant. Wrapping that in a declarative Flutter widget requires a +keyed diff, sync coalescing, and an equality contract that excludes callbacks. The +`onTap`-excluded `==` is a subtle, correct call that most implementations get wrong +and then blame on "MapLibre being flickery." + +**6. Overnight shift arithmetic.** Deceptively small. Bare `HH:mm:ss` with no date +makes "is the shift over?" ambiguous, and the answer inverts for shifts crossing +midnight. Getting it wrong means riders auto-clocked-out at 10 PM mid-delivery, or +never clocked out at all. + +**7. Graceful degradation as a design stance.** The four-tier distance cascade, +the four-tier start-time fallback, the fail-open geofence next to the fail-closed +coordinate validator — these aren't defensive-programming reflexes. Someone +decided, per code path, *which* wrong answer is least harmful, and the policies +differ where they should. That's the mark of software written by someone who had to +answer for its failures in the field. + +--- + +## Security & Sensitivity Flags + +**⚠️ Credentials in source — do not include any of this in a portfolio, and rotate +before publishing the repo.** All read directly, not inferred: + +| Location | Issue | +| --- | --- | +| `lib/controllers/deliveries_controller.dart:75-76` | DigitalOcean Spaces **access key + secret key** hardcoded. These ship inside the APK and are recoverable with `strings`. They grant object-storage writes. | +| `lib/views/helpers/constants/mqtt_constants.dart` | Broker **IP, username, and password** hardcoded. Port 1883 is **unencrypted MQTT** — credentials and every rider's live location cross the network in plaintext. | +| `lib/helpers/http_overrides.dart` | `badCertificateCallback` returns `true` unconditionally, installed globally in `main()` **and** in the background isolate. This disables TLS certificate validation app-wide, making every API call MITM-able. | +| Repo root & `android/` | `nearlerider-keystore.jks`, `release-key.jks`, `android/key.properties`, `upload_certificate.pem` are **tracked in git**. The signing keystore plus its password file is the set that lets someone publish as you. | +| git history | The previous Google Maps API key remains in history and in every already-shipped APK. It needs revoking in Google Cloud Console regardless of the migration. | + +**Commercially sensitive — describe generically, don't quote:** + +- The compensation formula and its rate key, bonus-point rules, and the 3-hour + skip-penalty window +- Geofence radius policy +- Backend host topology and endpoint paths (two distinct hosts, versioned APIs) +- MQTT topic schema and broker address + +**Inference vs. reading.** Everything above is read from source except: the backend +implementation (only its HTTP surface is visible from here), the server-side `step` +assignment algorithm (the app consumes it, doesn't compute it), and the actual +production billing figures for the old Google setup (the pricing model is +documented, the invoice isn't in the repo). + +**Also worth knowing for accuracy:** the app is Android-only in practice — +`abiFilters "arm64-v8a"`, and the iOS directory has no Podfile. The root +`README.md` is still the unmodified Flutter template. diff --git a/lib/background/backgroundservice.dart b/lib/background/backgroundservice.dart index 50b68b4..d4c3029 100644 --- a/lib/background/backgroundservice.dart +++ b/lib/background/backgroundservice.dart @@ -1,969 +1,969 @@ -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:nearle/providers/deliverylog/deliverylog_provider.dart'; -import 'package:nearle/providers/notifications/notificationservce.dart'; -import 'package:nearle/views/helpers/constants/apiconstants.dart'; -import 'package:nearle/utils/kalman_filter.dart'; - -/// Background service for managing active delivery logs -class BackgroundDeliveryLog { - static NearleKalmanFilter? _kf; - static DateTime? _lastUpdateTime; - - static final CreateDeliveryLogProvider _logProvider = - CreateDeliveryLogProvider(); - static final http.Client _httpClient = http.Client(); - static final FlutterTts _tts = FlutterTts(); - static final AudioPlayer _proximityPlayer = AudioPlayer(); - - static const double _idleThresholdMeters = 10; // ~5-10m tolerance - static const double _proximityThresholdMeters = 50.0; // 50 meters for arrival alert - static const String _activeDeliveriesKey = 'active_deliveries'; - static const String _payloadKeyPrefix = 'delivery_payload_'; - static const String _offlineLogKey = 'offline_delivery_logs'; - static const String _proximityAlertKeyPrefix = 'delivery_proximity_alerted_'; - - static bool _isPosting = false; - static bool _ttsReady = false; - static bool _audioReady = false; - - /// Process active deliveries: fetch from API, filter active, and post logs - static Future processActiveDeliveries() async { - if (_isPosting) { - debugPrint('[ACTIVE_DELIVERY_LOG][BG] Already posting, skipping...'); - return; - } - _isPosting = true; - - try { - // Get userid from SharedPreferences - final prefs = await SharedPreferences.getInstance(); - final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; - - debugPrint('[ACTIVE_DELIVERY_LOG][BG] Checking for user: $userId'); - - if (userId == 0) { - debugPrint('[ACTIVE_DELIVERY_LOG][BG] ❌ No user ID found in prefs'); - return; - } - - // Get current date dynamically (YYYY-MM-DD format) - final now = DateTime.now(); - final today = '${now.year}-${_pad(now.month)}-${_pad(now.day)}'; - - // Build API URL using v1 endpoint (getdeliveries) - final bool isLive = ApiConstants.mainRoute == 'live'; - final baseUrl = isLive - ? ApiConstants.currentDeliveryLive - : ApiConstants.currentDeliveryDev; - - final uri = Uri.parse(baseUrl).replace( - queryParameters: { - 'userid': userId.toString(), - 'fromdate': today, - 'todate': today, - 't': DateTime.now().millisecondsSinceEpoch.toString(), - }, - ); - - debugPrint('[ACTIVE_DELIVERY_LOG][BG] Fetching deliveries: $uri'); - - // Fetch deliveries from API using v1 endpoint - final deliveries = await _fetchDeliveriesFromApi(uri); - - debugPrint('[ACTIVE_DELIVERY_LOG][BG] API returned ${deliveries.length} items'); - - // Filter for active orders only - final activeOrders = deliveries.whereType>().where(( - order, - ) { - final status = (order['orderstatus']?.toString().toLowerCase() ?? '') - .trim(); - final isActive = status == 'active'; - return isActive; - }).toList(); - - if (activeOrders.isEmpty) { - debugPrint('[ACTIVE_DELIVERY_LOG][BG] No active orders found'); - // ✅ Still check shift end even if no active deliveries - debugPrint('[ACTIVE_DELIVERY_LOG][BG] 🔍 Checking shift end time...'); - await checkShiftEnd(); - return; - } - - debugPrint( - '[ACTIVE_DELIVERY_LOG][BG] Found ${activeOrders.length} active deliveries', - ); - - // Post logs for each active delivery - for (final order in activeOrders) { - final orderId = (order['orderid'] ?? '').toString(); - if (orderId.isEmpty) continue; - - // Get current coordinates once per order to reuse for proximity + log - final Map? coords = await _getCoordinatesWithFallback(); - if (coords != null) { - await _checkProximityAlert(order, coords); - } - - // Get payload from SharedPreferences or create new one - Map? payload = await _loadPayload(orderId); - if (payload == null) { - // Create new payload from order data with userId from SharedPreferences - payload = _createPayload(order, userId); - await _persistPayload(orderId, payload); - await _markAsActive(orderId); - } else { - // Ensure userid is set correctly in existing payload - payload['userid'] = userId; - } - - // Post the log - await _postDeliveryLog(orderId, payload, currentCoords: coords); - } - - // ✅ CRITICAL: Check if shift end time has passed (backup to alarm) - await checkShiftEnd(); - - // Attempt to flush offline logs - await _flushOfflineLogs(); - } catch (e) { - debugPrint('[ACTIVE_DELIVERY_LOG][BG] Error processing: $e'); - } finally { - _isPosting = false; - } - } - - /// Post delivery log for a specific order - static Future _postDeliveryLog( - String orderId, - Map payload, { - Map? currentCoords, - }) async { - Map? payloadWithCoords; - try { - // Get current coordinates - final coords = currentCoords ?? await _getCoordinatesWithFallback(); - if (coords == null) { - return; - } - - // Distance calculation is now fully handled by LiveTrackingService in the background. - // 03-14: Removed redundant 60-second background calculations to avoid logic conflicts. - final deliveryId = payload['deliveryid']?.toString() ?? ''; - - - // Create log date timestamp - final now = DateTime.now(); - final logDate = - '${now.year}-${_pad(now.month)}-${_pad(now.day)} ${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; - - // Retrieve final cumulative KM to send - double riderKmsToSend = 0.0; - if (deliveryId.isNotEmpty && deliveryId != '0') { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.reload(); // Force reload so we get the latest value written by the main isolate - riderKmsToSend = double.tryParse(prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? '0') ?? 0.0; - } catch (_) {} - } - - // Build payload with coordinates and timestamp - payloadWithCoords = { - ...payload, - 'logdate': logDate, - 'latitude': coords['lat'] ?? '0', - 'longitude': coords['lng'] ?? '0', - 'raw_latitude': coords['raw_lat'] ?? '0', - 'raw_longitude': coords['raw_lng'] ?? '0', - 'velocity_lat': coords['velocity_lat'] ?? '0', - 'velocity_lng': coords['velocity_lng'] ?? '0', - 'speed': coords['speed'] ?? '0', - 'heading': coords['heading'] ?? '0', - 'riderkms': riderKmsToSend, - 'logstatus': await _resolveLogStatus(orderId, coords), - }; - - // Determine API endpoint - final url = ApiConstants.mainRoute == 'live' - ? ApiConstants.createDeliveryLogLive - : ApiConstants.createDeliveryLogDev; - - // Post the log - debugPrint('[ACTIVE_DELIVERY_LOG][BG] Posting log for $orderId'); - - final result = await _logProvider - .createDeliveryLog(url, payloadWithCoords) - .timeout( - const Duration(seconds: 8), - onTimeout: () => throw TimeoutException( - 'Delivery log post timeout for $orderId', - ), - ); - - if (result != null) { - debugPrint('[ACTIVE_DELIVERY_LOG][BG] Success for $orderId'); - await _persistLastLogLocation(orderId, coords); - } else { - debugPrint( - '[ACTIVE_DELIVERY_LOG][BG] Warning: No response for $orderId', - ); - } - } catch (e) { - debugPrint( - '[ACTIVE_DELIVERY_LOG][BG] Failed to post log for $orderId: $e', - ); - // Offline fallback - if (payloadWithCoords != null) { - await _saveToOfflineQueue(orderId, payloadWithCoords); - } - } - } - - /// Create payload from delivery order data - static Map _createPayload( - Map delivery, - int userId, - ) { - return { - 'logid': 0, - 'tenantid': delivery['tenantid'] ?? 0, - 'partnerid': delivery['partnerid'] ?? 0, - 'locationid': delivery['locationid'] ?? 0, - 'orderheaderid': delivery['orderheaderid'] ?? 0, - 'deliveryid': delivery['deliveryid'] ?? 0, - 'userid': userId, - 'orderid': (delivery['orderid'] ?? '').toString(), - 'orderstatus': 'active', - }; - } - - /// Get coordinates with fallback (current position -> last known -> cached) - static Future?> _getCoordinatesWithFallback() async { - Map result = { - 'lat': '0', - 'lng': '0', - 'raw_lat': '0', - 'raw_lng': '0', - 'speed': '0', - 'heading': '0', - 'velocity_lat': '0', - 'velocity_lng': '0', - }; - - try { - // 1. Check if location services are enabled - final serviceEnabled = await Geolocator.isLocationServiceEnabled(); - if (!serviceEnabled) { - debugPrint('[ACTIVE_DELIVERY_LOG][BG] Location services disabled'); - return await _getCachedCoordinatesAsMap(); - } - - // 2. Check permissions - final permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { - debugPrint('[ACTIVE_DELIVERY_LOG][BG] Location permission denied'); - return await _getCachedCoordinatesAsMap(); - } - - Position? position; - try { - position = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.high, - ), - ).timeout(const Duration(seconds: 5)); - } catch (e) { - debugPrint('[ACTIVE_DELIVERY_LOG][BG] Failed to get current pos: $e'); - position = await Geolocator.getLastKnownPosition(); - } - - if (position != null) { - // Reject mocked GPS (anti-cheat) - if (position.isMocked) { - debugPrint('[ACTIVE_DELIVERY_LOG][BG] Mocked position — using cached'); - return await _getCachedCoordinatesAsMap(); - } - // Reject very poor accuracy to avoid phantom movements in delivery logs - if (position.accuracy > 50.0) { - debugPrint( - '[ACTIVE_DELIVERY_LOG][BG] Low-accuracy position (${position.accuracy.toStringAsFixed(0)}m) — using cached', - ); - return await _getCachedCoordinatesAsMap(); - } - - final now = DateTime.now(); - double outLat = position.latitude; - double outLng = position.longitude; - double speed = position.speed; - double heading = position.heading; - - // Decompose velocity for Kalman - final double headingRadians = heading * (math.pi / 180.0); - final double velocityLng = speed * math.sin(headingRadians); - final double velocityLat = speed * math.cos(headingRadians); - - if (_kf == null) { - _kf = NearleKalmanFilter(lat: outLat, lng: outLng); - } else { - final double dt = _lastUpdateTime != null - ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 - : 30.0; - - _kf!.predict(dt); - _kf!.update(outLat, outLng); - outLat = _kf!.x[0]; - outLng = _kf!.x[1]; - } - _lastUpdateTime = now; - - result = { - 'lat': outLat.toString(), - 'lng': outLng.toString(), - 'raw_lat': position.latitude.toString(), - 'raw_lng': position.longitude.toString(), - 'speed': speed.toStringAsFixed(2), - 'heading': heading.toStringAsFixed(2), - 'velocity_lat': velocityLat.toStringAsFixed(4), - 'velocity_lng': velocityLng.toStringAsFixed(4), - }; - - await _cacheCoordinates(result['lat']!, result['lng']!); - return result; - } - - return await _getCachedCoordinatesAsMap(); - } catch (e) { - debugPrint('[ACTIVE_DELIVERY_LOG][BG] Error getting coords: $e'); - return await _getCachedCoordinatesAsMap(); - } - } - - static Future?> _getCachedCoordinatesAsMap() async { - final coords = await _getCachedCoordinates(); - if (coords == null) return null; - return { - 'lat': coords.$1, - 'lng': coords.$2, - 'raw_lat': coords.$1, - 'raw_lng': coords.$2, - 'speed': '0', - 'heading': '0', - 'velocity_lat': '0', - 'velocity_lng': '0', - }; - } - - /// Cache coordinates to SharedPreferences - static Future _cacheCoordinates(String lat, String lng) async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('last_lat', lat); - await prefs.setString('last_lng', lng); - } catch (_) {} - } - - /// Get cached coordinates from SharedPreferences - static Future<(String, String)?> _getCachedCoordinates() async { - try { - final prefs = await SharedPreferences.getInstance(); - final lat = (prefs.getString('last_lat') ?? '').trim(); - final lng = (prefs.getString('last_lng') ?? '').trim(); - if (lat.isNotEmpty && lng.isNotEmpty) { - return (lat, lng); - } - } catch (_) {} - return null; - } - - /// Load payload from SharedPreferences - static Future?> _loadPayload(String orderId) async { - try { - final prefs = await SharedPreferences.getInstance(); - final jsonStr = prefs.getString('$_payloadKeyPrefix$orderId'); - if (jsonStr == null || jsonStr.isEmpty) return null; - final decoded = jsonDecode(jsonStr); - if (decoded is Map) return decoded; - if (decoded is Map) return decoded.cast(); - } catch (_) {} - return null; - } - - /// Persist payload to SharedPreferences - static Future _persistPayload( - String orderId, - Map payload, - ) async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('$_payloadKeyPrefix$orderId', jsonEncode(payload)); - } catch (_) {} - } - - /// Mark order as active in SharedPreferences - static Future _markAsActive(String orderId) async { - try { - final prefs = await SharedPreferences.getInstance(); - final list = prefs.getStringList(_activeDeliveriesKey) ?? []; - if (!list.contains(orderId)) { - list.add(orderId); - await prefs.setStringList(_activeDeliveriesKey, list); - } - } catch (_) {} - } - - /// Fetch deliveries from API using v1 endpoint - static Future> _fetchDeliveriesFromApi(Uri uri) async { - try { - final res = await _httpClient - .get(uri) - .timeout(const Duration(seconds: 10)); - - if (res.statusCode >= 200 && res.statusCode < 300) { - final decoded = json.decode(res.body); - - final data = decoded is Map - ? (decoded['details'] ?? decoded['data'] ?? decoded) - : decoded; - - if (data is List) { - return data; - } - if (data is Map && data['items'] is List) { - return data['items'] as List; - } - - return []; - } - return []; - } catch (e) { - return []; - } - } - - /// Helper to pad numbers with leading zero - static String _pad(int n) => n.toString().padLeft(2, '0'); - - static Future _resolveLogStatus( - String orderId, - Map coords, - ) async { - try { - final last = await _loadLastLogLocation(orderId); - if (last != null) { - final lastLat = last.$1; - final lastLng = last.$2; - final currentLat = double.tryParse(coords['lat'] ?? '0') ?? 0; - final currentLng = double.tryParse(coords['lng'] ?? '0') ?? 0; - if (currentLat != 0 && - currentLng != 0 && - lastLat != 0 && - lastLng != 0) { - final distance = Geolocator.distanceBetween( - lastLat, - lastLng, - currentLat, - currentLng, - ); - if (distance <= _idleThresholdMeters) { - return 1; // idle - } - } - } - } catch (e) { - debugPrint('[ACTIVE_DELIVERY_LOG][BG] logstatus error: $e'); - } - return 0; // moving - } - - static Future<(double, double)?> _loadLastLogLocation(String orderId) async { - try { - final prefs = await SharedPreferences.getInstance(); - final jsonStr = prefs.getString('last_log_loc_$orderId'); - if (jsonStr == null || jsonStr.isEmpty) return null; - final decoded = jsonDecode(jsonStr); - if (decoded is Map) { - final lat = double.tryParse('${decoded['lat']}') ?? 0; - final lng = double.tryParse('${decoded['lng']}') ?? 0; - if (lat != 0 && lng != 0) return (lat, lng); - } - } catch (_) {} - return null; - } - - static Future _persistLastLogLocation( - String orderId, - Map coords, - ) async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString( - 'last_log_loc_$orderId', - jsonEncode({'lat': coords['lat'], 'lng': coords['lng']}), - ); - } catch (_) {} - } - - // ---------------- Offline Queue Logic ---------------- - - static Future _flushOfflineLogs() async { - try { - final prefs = await SharedPreferences.getInstance(); - final List queue = prefs.getStringList(_offlineLogKey) ?? []; - if (queue.isEmpty) return; - - debugPrint( - '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Flushing ${queue.length} offline logs...', - ); - - final List remaining = []; - bool anySuccess = false; - - final url = ApiConstants.mainRoute == 'live' - ? ApiConstants.createDeliveryLogLive - : ApiConstants.createDeliveryLogDev; - - for (final itemStr in queue) { - try { - final Map item = jsonDecode(itemStr); - final String orderId = item['orderId'] ?? ''; - final Map payload = Map.from( - item['payload'] ?? {}, - ); - - if (payload.isEmpty) continue; - - debugPrint( - '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Retrying for orderId: $orderId', - ); - - final result = await _logProvider - .createDeliveryLog(url, payload) - .timeout(const Duration(seconds: 8)); - - if (result != null) { - debugPrint( - '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Success for orderId: $orderId', - ); - anySuccess = true; - } else { - remaining.add(itemStr); - } - } catch (e) { - remaining.add(itemStr); - } - } - - if (anySuccess || remaining.length != queue.length) { - await prefs.setStringList(_offlineLogKey, remaining); - } - } catch (e) { - debugPrint('[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Flush error: $e'); - } - } - - static Future _saveToOfflineQueue( - String orderId, - Map payload, - ) async { - try { - final prefs = await SharedPreferences.getInstance(); - final List queue = prefs.getStringList(_offlineLogKey) ?? []; - - final item = jsonEncode({ - 'orderId': orderId, - 'payload': payload, - 'timestamp': DateTime.now().millisecondsSinceEpoch, - }); - - queue.add(item); - await prefs.setStringList(_offlineLogKey, queue); - debugPrint( - '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Saved to queue. Total: ${queue.length}', - ); - } catch (e) { - debugPrint( - '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Error saving to queue: $e', - ); - } - } - - // ---------------- Auto Shift End Logic ---------------- - - /// Check if shift has ended and trigger auto-break if needed - static Future checkShiftEnd() async { - try { - debugPrint('[AUTO_SHIFT_END] 🔍 Starting shift end check...'); - final prefs = await SharedPreferences.getInstance(); - - // 1. Check if currently On Duty - final int onduty = prefs.getInt('onduty') ?? 0; - debugPrint('[AUTO_SHIFT_END] 📊 Current onduty status: $onduty'); - if (onduty != 1) { - debugPrint('[AUTO_SHIFT_END] ⏭️ Rider not on duty, skipping check'); - return; // Already offline - } - - // 2. Get Shift Timings - final String startTimeStr = prefs.getString('starttime') ?? ''; - final String endTimeStr = prefs.getString('endtime') ?? ''; - debugPrint('[AUTO_SHIFT_END] ⏰ Shift times - Start: "$startTimeStr", End: "$endTimeStr"'); - - if (endTimeStr.isEmpty) { - debugPrint('[AUTO_SHIFT_END] ⚠️ No endtime found, cannot check shift end'); - return; - } - - // 3. Parse Times (Assumed format HH:mm:ss) - final now = DateTime.now(); - - final endParts = endTimeStr.split(':'); - if (endParts.length < 2) return; - - final int endHour = int.tryParse(endParts[0]) ?? 0; - final int endMinute = int.tryParse(endParts[1]) ?? 0; - final int endSecond = endParts.length > 2 ? (int.tryParse(endParts[2]) ?? 0) : 0; - - // Create DateTime for end time on TODAY - final DateTime endToday = DateTime( - now.year, - now.month, - now.day, - endHour, - endMinute, - endSecond, - ); - - bool isShiftOver = false; - - if (startTimeStr.isNotEmpty) { - final startParts = startTimeStr.split(':'); - if (startParts.length >= 2) { - final int startHour = int.tryParse(startParts[0]) ?? 0; - final int startMinute = int.tryParse(startParts[1]) ?? 0; - - // Create DateTime for start time on TODAY - final DateTime startToday = DateTime( - now.year, - now.month, - now.day, - startHour, - startMinute, - ); - - // Check for overnight shift (Start > End in 24-hour format, e.g. 22:00 to 06:00) - // This means shift crosses midnight - final double startVal = startHour + (startMinute / 60.0); - final double endVal = endHour + (endMinute / 60.0); - - if (startVal > endVal) { - // ✅ OVERNIGHT SHIFT (crosses midnight, e.g. 22:00 to 06:00) - // Shift is over if current time is AFTER end time today AND BEFORE start time today - // Example: End 06:00, Start 22:00 - // - Now 23:00 -> After 22:00 (start) -> Still in shift (ACTIVE) - // - Now 05:00 -> Before 06:00 (end) -> Still in shift from yesterday (ACTIVE) - // - Now 10:00 -> After 06:00 (end) AND Before 22:00 (start) -> Shift OVER - - debugPrint('[AUTO_SHIFT_END] 🌙 Overnight shift detected (Start: ${_pad(startHour)}:${_pad(startMinute)}, End: ${_pad(endHour)}:${_pad(endMinute)})'); - debugPrint('[AUTO_SHIFT_END] 📅 Current time: ${_pad(now.hour)}:${_pad(now.minute)}'); - - if (now.isAfter(endToday) && now.isBefore(startToday)) { - // We're in the gap period between end and start -> shift is OVER - isShiftOver = true; - debugPrint('[AUTO_SHIFT_END] ✅ Overnight shift: In gap period -> SHIFT OVER'); - } else { - // We're either before end (still in shift from yesterday) or after start (still in shift today) - isShiftOver = false; - debugPrint('[AUTO_SHIFT_END] ✅ Overnight shift: Still active'); - } - } else { - // ✅ NORMAL DAY SHIFT (e.g. 09:00 to 17:00, doesn't cross midnight) - // Shift is over if current time is AFTER end time - debugPrint('[AUTO_SHIFT_END] ☀️ Day shift detected (Start: ${_pad(startHour)}:${_pad(startMinute)}, End: ${_pad(endHour)}:${_pad(endMinute)})'); - debugPrint('[AUTO_SHIFT_END] 📅 Current time: ${_pad(now.hour)}:${_pad(now.minute)}, End time: ${_pad(endHour)}:${_pad(endMinute)}'); - - if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { - isShiftOver = true; - debugPrint('[AUTO_SHIFT_END] ✅ Day shift: Shift ended'); - } else { - isShiftOver = false; - debugPrint('[AUTO_SHIFT_END] ✅ Day shift: Still active'); - } - } - } else { - // Fallback if start time parse fails: assume day shift - debugPrint('[AUTO_SHIFT_END] ⚠️ Could not parse start time, assuming day shift'); - if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { - isShiftOver = true; - } - } - } else { - // Fallback if no start time: assume day shift - debugPrint('[AUTO_SHIFT_END] ⚠️ No start time provided, assuming day shift'); - if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { - isShiftOver = true; - } - } - - // 4. Trigger Auto End if Shift is Over - debugPrint('[AUTO_SHIFT_END] 📅 Time check result - isShiftOver: $isShiftOver, Current: ${_pad(now.hour)}:${_pad(now.minute)}, End: ${_pad(endHour)}:${_pad(endMinute)}'); - if (isShiftOver) { - debugPrint('[AUTO_SHIFT_END] ⏰ Shift ended (Start: $startTimeStr, End: $endTimeStr). Current: ${_pad(now.hour)}:${_pad(now.minute)}'); - await _autoEndShift(prefs); - } else { - debugPrint('[AUTO_SHIFT_END] ✅ Shift not ended yet, continuing...'); - } - } catch (e) { - debugPrint('[AUTO_SHIFT_END] ❌ Error checking shift end: $e'); - debugPrint('[AUTO_SHIFT_END] Stack trace: ${StackTrace.current}'); - } - } - - static Future _autoEndShift(SharedPreferences prefs) async { - try { - debugPrint( - '[AUTO_SHIFT_END] 🚀 Initiating auto-break and offline sequence...', - ); - - // 1. Get Required IDs - final int userid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; - final int partnerid = - prefs.getInt('partnerid') ?? prefs.getInt('partnerId') ?? 0; - final int shiftid = - prefs.getInt('shiftid') ?? prefs.getInt('shiftId') ?? 0; - final int logid = prefs.getInt('logid') ?? prefs.getInt('logId') ?? 0; - - if (userid == 0) { - debugPrint( - '[AUTO_SHIFT_END] ❌ Missing userid, cannot create break log', - ); - return; - } - - // 2. Get Location - final Map? coords = await _getCoordinatesWithFallback(); - final String lat = coords?['lat'] ?? '0'; - final String lng = coords?['lng'] ?? '0'; - - // 3. Prepare Break Log Payload - final now = DateTime.now(); - final int localBreakId = - (DateTime.now().millisecondsSinceEpoch % 900) + 100; // Random-ish ID - final String breakdate = - '${now.year}-${_pad(now.month)}-${_pad(now.day)} ${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; - final String breakstart = - '${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; - - final payload = { - "breakid": localBreakId, - "logid": logid, - "breakdate": breakdate, - "userid": userid, - "partnerid": partnerid, - "shiftid": shiftid, - "breakstart": breakstart, - "breakend": "", - "breakhours": 0.0, - "latitude": lat, - "longitude": lng, - }; - - // 4. Call API to Create Break - final url = ApiConstants.mainRoute == 'live' - ? ApiConstants.createBreakRiderLogLive - : ApiConstants.createBreakRiderLogDev; - - debugPrint('[AUTO_SHIFT_END] Creating break log: $url'); - - // We use a separate provider instance or http call if needed, - // but _logProvider is for delivery logs. We need a generic post or use http directly. - // Since we don't have BreakRiderLogProvider here, we'll use http directly for simplicity and isolation. - - try { - final response = await _httpClient - .post( - Uri.parse(url), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(payload), - ) - .timeout(const Duration(seconds: 10)); - - if (response.statusCode >= 200 && response.statusCode < 300) { - debugPrint('[AUTO_SHIFT_END] ✅ Break log created successfully'); - - // Parse response to get server break ID if needed, but mainly we just need to go offline - final body = jsonDecode(response.body); - final det = (body['details'] is Map) ? body['details'] : body; - final serverBreakId = det['breakid']; - - if (serverBreakId != null) { - await prefs.setInt( - 'breakId', - int.tryParse('$serverBreakId') ?? localBreakId, - ); - } - await prefs.setString('breakStart', breakstart); - await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch); - } else { - debugPrint( - '[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${response.statusCode}', - ); - } - } catch (e) { - debugPrint('[AUTO_SHIFT_END] ❌ API Error: $e'); - // Even if API fails, we MUST go offline locally to prevent further issues - } - - // 5. Set Offline Locally - await prefs.setInt('onduty', 0); - await prefs.setBool('online', false); - - // 6. Update Rider Log (Set Duty = 0) - // We should also update the main rider log to say onduty=0 - final updateUrl = ApiConstants.mainRoute == 'live' - ? ApiConstants.updateRiderLogLive - : ApiConstants.updateRiderLogDev; - - final updatePayload = { - "userid": userid, - "onduty": 0, - "latitude": lat, - "longitude": lng, - }; - - try { - await _httpClient - .post( - Uri.parse(updateUrl), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(updatePayload), - ) - .timeout(const Duration(seconds: 5)); - debugPrint('[AUTO_SHIFT_END] ✅ Rider status updated to Offline'); - } catch (_) {} - - debugPrint('[AUTO_SHIFT_END] 🏁 Auto-shift end sequence complete.'); - } catch (e) { - debugPrint('[AUTO_SHIFT_END] Critical error in _autoEndShift: $e'); - } - } - - /// Trigger proximity alert once per delivery when within threshold - static Future _checkProximityAlert( - Map order, - Map coords, - ) async { - try { - final deliveryId = - (order['deliveryid'] ?? order['orderid'] ?? '').toString(); - if (deliveryId.isEmpty) return; - - final dropLatRaw = - (order['droplat'] ?? order['DropLat'] ?? order['deliverylat'] ?? '') - .toString(); - final dropLngRaw = (order['droplon'] ?? - order['droplong'] ?? - order['DropLon'] ?? - order['DropLong'] ?? - order['deliverylong'] ?? - '') - .toString(); - final dropLat = double.tryParse(dropLatRaw) ?? 0.0; - final dropLng = double.tryParse(dropLngRaw) ?? 0.0; - if (dropLat == 0 || - dropLng == 0 || - dropLat.abs() > 90 || - dropLng.abs() > 180) { - return; - } - - final riderLat = double.tryParse(coords['lat'] ?? '0') ?? 0.0; - final riderLng = double.tryParse(coords['lng'] ?? '0') ?? 0.0; - if (riderLat == 0 || - riderLng == 0 || - riderLat.abs() > 90 || - riderLng.abs() > 180) { - return; - } - - final distanceMeters = Geolocator.distanceBetween( - dropLat, - dropLng, - riderLat, - riderLng, - ); - - if (distanceMeters <= _proximityThresholdMeters) { - final prefs = await SharedPreferences.getInstance(); - final alreadyAlerted = prefs.getBool(‘$_proximityAlertKeyPrefix$deliveryId’) ?? false; - if (alreadyAlerted) { - debugPrint(‘[PROXIMITY] Already alerted for deliveryId=$deliveryId, skipping’); - return; - } - - await NotificationServce.showLocalNotification( - title: ‘Near delivery location’, - body: ‘You\’ve reached your destination. Please update the status.’, - ); - - final played = await _playProximityAudio(); - await prefs.setBool(‘$_proximityAlertKeyPrefix$deliveryId’, true); - - debugPrint( - ‘[PROXIMITY] Alerted deliveryId=$deliveryId at ${distanceMeters.toStringAsFixed(1)}m ‘ - ‘target=($dropLat,$dropLng) rider=($riderLat,$riderLng) played=$played’, - ); - } else { - debugPrint( - ‘[PROXIMITY] Skipped alert for deliveryId=$deliveryId | distance=${distanceMeters.toStringAsFixed(1)}m’, - ); - } - } catch (e) { - debugPrint('[PROXIMITY] Error sending alert: $e'); - } - } - - static Future _playProximityAudio() async { - try { - if (!_audioReady) { - await _proximityPlayer.setReleaseMode(ReleaseMode.stop); - await _proximityPlayer.setVolume(1.0); - _audioReady = true; - } - // Attempt to play bundled destination audio - await _proximityPlayer.stop(); - await _proximityPlayer.play(AssetSource('audio/destination.mp3')); - debugPrint('[PROXIMITY][AUDIO] Playing destination.mp3'); - return true; - } catch (e) { - debugPrint('[PROXIMITY][AUDIO] Error playing destination clip: $e'); - } - - // Fallback to TTS if audio fails - try { - // Initialize once - if (!_ttsReady) { - await _tts.setLanguage('en-US'); - await _tts.setSpeechRate(0.9); - await _tts.setVolume(1.0); - await _tts.setPitch(1.0); - _ttsReady = true; - } - // Speak without awaiting completion to avoid blocking - await _tts.speak('You have reached your destination. Please update the delivery status.'); - debugPrint('[PROXIMITY][TTS] Spoke destination prompt'); - return true; - } catch (e) { - debugPrint('[PROXIMITY][TTS] Error speaking prompt: $e'); - } - return false; - } -} +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:nearle/providers/deliverylog/deliverylog_provider.dart'; +import 'package:nearle/providers/notifications/notificationservce.dart'; +import 'package:nearle/views/helpers/constants/apiconstants.dart'; +import 'package:nearle/utils/kalman_filter.dart'; + +/// Background service for managing active delivery logs +class BackgroundDeliveryLog { + static NearleKalmanFilter? _kf; + static DateTime? _lastUpdateTime; + + static final CreateDeliveryLogProvider _logProvider = + CreateDeliveryLogProvider(); + static final http.Client _httpClient = http.Client(); + static final FlutterTts _tts = FlutterTts(); + static final AudioPlayer _proximityPlayer = AudioPlayer(); + + static const double _idleThresholdMeters = 10; // ~5-10m tolerance + static const double _proximityThresholdMeters = 50.0; // 50 meters for arrival alert + static const String _activeDeliveriesKey = 'active_deliveries'; + static const String _payloadKeyPrefix = 'delivery_payload_'; + static const String _offlineLogKey = 'offline_delivery_logs'; + static const String _proximityAlertKeyPrefix = 'delivery_proximity_alerted_'; + + static bool _isPosting = false; + static bool _ttsReady = false; + static bool _audioReady = false; + + /// Process active deliveries: fetch from API, filter active, and post logs + static Future processActiveDeliveries() async { + if (_isPosting) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Already posting, skipping...'); + return; + } + _isPosting = true; + + try { + // Get userid from SharedPreferences + final prefs = await SharedPreferences.getInstance(); + final userId = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Checking for user: $userId'); + + if (userId == 0) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] ❌ No user ID found in prefs'); + return; + } + + // Get current date dynamically (YYYY-MM-DD format) + final now = DateTime.now(); + final today = '${now.year}-${_pad(now.month)}-${_pad(now.day)}'; + + // Build API URL using v1 endpoint (getdeliveries) + final bool isLive = ApiConstants.mainRoute == 'live'; + final baseUrl = isLive + ? ApiConstants.currentDeliveryLive + : ApiConstants.currentDeliveryDev; + + final uri = Uri.parse(baseUrl).replace( + queryParameters: { + 'userid': userId.toString(), + 'fromdate': today, + 'todate': today, + 't': DateTime.now().millisecondsSinceEpoch.toString(), + }, + ); + + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Fetching deliveries: $uri'); + + // Fetch deliveries from API using v1 endpoint + final deliveries = await _fetchDeliveriesFromApi(uri); + + debugPrint('[ACTIVE_DELIVERY_LOG][BG] API returned ${deliveries.length} items'); + + // Filter for active orders only + final activeOrders = deliveries.whereType>().where(( + order, + ) { + final status = (order['orderstatus']?.toString().toLowerCase() ?? '') + .trim(); + final isActive = status == 'active'; + return isActive; + }).toList(); + + if (activeOrders.isEmpty) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] No active orders found'); + // ✅ Still check shift end even if no active deliveries + debugPrint('[ACTIVE_DELIVERY_LOG][BG] 🔍 Checking shift end time...'); + await checkShiftEnd(); + return; + } + + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] Found ${activeOrders.length} active deliveries', + ); + + // Post logs for each active delivery + for (final order in activeOrders) { + final orderId = (order['orderid'] ?? '').toString(); + if (orderId.isEmpty) continue; + + // Get current coordinates once per order to reuse for proximity + log + final Map? coords = await _getCoordinatesWithFallback(); + if (coords != null) { + await _checkProximityAlert(order, coords); + } + + // Get payload from SharedPreferences or create new one + Map? payload = await _loadPayload(orderId); + if (payload == null) { + // Create new payload from order data with userId from SharedPreferences + payload = _createPayload(order, userId); + await _persistPayload(orderId, payload); + await _markAsActive(orderId); + } else { + // Ensure userid is set correctly in existing payload + payload['userid'] = userId; + } + + // Post the log + await _postDeliveryLog(orderId, payload, currentCoords: coords); + } + + // ✅ CRITICAL: Check if shift end time has passed (backup to alarm) + await checkShiftEnd(); + + // Attempt to flush offline logs + await _flushOfflineLogs(); + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Error processing: $e'); + } finally { + _isPosting = false; + } + } + + /// Post delivery log for a specific order + static Future _postDeliveryLog( + String orderId, + Map payload, { + Map? currentCoords, + }) async { + Map? payloadWithCoords; + try { + // Get current coordinates + final coords = currentCoords ?? await _getCoordinatesWithFallback(); + if (coords == null) { + return; + } + + // Distance calculation is now fully handled by LiveTrackingService in the background. + // 03-14: Removed redundant 60-second background calculations to avoid logic conflicts. + final deliveryId = payload['deliveryid']?.toString() ?? ''; + + + // Create log date timestamp + final now = DateTime.now(); + final logDate = + '${now.year}-${_pad(now.month)}-${_pad(now.day)} ${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; + + // Retrieve final cumulative KM to send + double riderKmsToSend = 0.0; + if (deliveryId.isNotEmpty && deliveryId != '0') { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); // Force reload so we get the latest value written by the main isolate + riderKmsToSend = double.tryParse(prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? '0') ?? 0.0; + } catch (_) {} + } + + // Build payload with coordinates and timestamp + payloadWithCoords = { + ...payload, + 'logdate': logDate, + 'latitude': coords['lat'] ?? '0', + 'longitude': coords['lng'] ?? '0', + 'raw_latitude': coords['raw_lat'] ?? '0', + 'raw_longitude': coords['raw_lng'] ?? '0', + 'velocity_lat': coords['velocity_lat'] ?? '0', + 'velocity_lng': coords['velocity_lng'] ?? '0', + 'speed': coords['speed'] ?? '0', + 'heading': coords['heading'] ?? '0', + 'riderkms': riderKmsToSend, + 'logstatus': await _resolveLogStatus(orderId, coords), + }; + + // Determine API endpoint + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + // Post the log + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Posting log for $orderId'); + + final result = await _logProvider + .createDeliveryLog(url, payloadWithCoords) + .timeout( + const Duration(seconds: 8), + onTimeout: () => throw TimeoutException( + 'Delivery log post timeout for $orderId', + ), + ); + + if (result != null) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Success for $orderId'); + await _persistLastLogLocation(orderId, coords); + } else { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] Warning: No response for $orderId', + ); + } + } catch (e) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] Failed to post log for $orderId: $e', + ); + // Offline fallback + if (payloadWithCoords != null) { + await _saveToOfflineQueue(orderId, payloadWithCoords); + } + } + } + + /// Create payload from delivery order data + static Map _createPayload( + Map delivery, + int userId, + ) { + return { + 'logid': 0, + 'tenantid': delivery['tenantid'] ?? 0, + 'partnerid': delivery['partnerid'] ?? 0, + 'locationid': delivery['locationid'] ?? 0, + 'orderheaderid': delivery['orderheaderid'] ?? 0, + 'deliveryid': delivery['deliveryid'] ?? 0, + 'userid': userId, + 'orderid': (delivery['orderid'] ?? '').toString(), + 'orderstatus': 'active', + }; + } + + /// Get coordinates with fallback (current position -> last known -> cached) + static Future?> _getCoordinatesWithFallback() async { + Map result = { + 'lat': '0', + 'lng': '0', + 'raw_lat': '0', + 'raw_lng': '0', + 'speed': '0', + 'heading': '0', + 'velocity_lat': '0', + 'velocity_lng': '0', + }; + + try { + // 1. Check if location services are enabled + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Location services disabled'); + return await _getCachedCoordinatesAsMap(); + } + + // 2. Check permissions + final permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Location permission denied'); + return await _getCachedCoordinatesAsMap(); + } + + Position? position; + try { + position = await Geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ).timeout(const Duration(seconds: 5)); + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Failed to get current pos: $e'); + position = await Geolocator.getLastKnownPosition(); + } + + if (position != null) { + // Reject mocked GPS (anti-cheat) + if (position.isMocked) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Mocked position — using cached'); + return await _getCachedCoordinatesAsMap(); + } + // Reject very poor accuracy to avoid phantom movements in delivery logs + if (position.accuracy > 50.0) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG] Low-accuracy position (${position.accuracy.toStringAsFixed(0)}m) — using cached', + ); + return await _getCachedCoordinatesAsMap(); + } + + final now = DateTime.now(); + double outLat = position.latitude; + double outLng = position.longitude; + double speed = position.speed; + double heading = position.heading; + + // Decompose velocity for Kalman + final double headingRadians = heading * (math.pi / 180.0); + final double velocityLng = speed * math.sin(headingRadians); + final double velocityLat = speed * math.cos(headingRadians); + + if (_kf == null) { + _kf = NearleKalmanFilter(lat: outLat, lng: outLng); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : 30.0; + + _kf!.predict(dt); + _kf!.update(outLat, outLng); + outLat = _kf!.x[0]; + outLng = _kf!.x[1]; + } + _lastUpdateTime = now; + + result = { + 'lat': outLat.toString(), + 'lng': outLng.toString(), + 'raw_lat': position.latitude.toString(), + 'raw_lng': position.longitude.toString(), + 'speed': speed.toStringAsFixed(2), + 'heading': heading.toStringAsFixed(2), + 'velocity_lat': velocityLat.toStringAsFixed(4), + 'velocity_lng': velocityLng.toStringAsFixed(4), + }; + + await _cacheCoordinates(result['lat']!, result['lng']!); + return result; + } + + return await _getCachedCoordinatesAsMap(); + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] Error getting coords: $e'); + return await _getCachedCoordinatesAsMap(); + } + } + + static Future?> _getCachedCoordinatesAsMap() async { + final coords = await _getCachedCoordinates(); + if (coords == null) return null; + return { + 'lat': coords.$1, + 'lng': coords.$2, + 'raw_lat': coords.$1, + 'raw_lng': coords.$2, + 'speed': '0', + 'heading': '0', + 'velocity_lat': '0', + 'velocity_lng': '0', + }; + } + + /// Cache coordinates to SharedPreferences + static Future _cacheCoordinates(String lat, String lng) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('last_lat', lat); + await prefs.setString('last_lng', lng); + } catch (_) {} + } + + /// Get cached coordinates from SharedPreferences + static Future<(String, String)?> _getCachedCoordinates() async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = (prefs.getString('last_lat') ?? '').trim(); + final lng = (prefs.getString('last_lng') ?? '').trim(); + if (lat.isNotEmpty && lng.isNotEmpty) { + return (lat, lng); + } + } catch (_) {} + return null; + } + + /// Load payload from SharedPreferences + static Future?> _loadPayload(String orderId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = prefs.getString('$_payloadKeyPrefix$orderId'); + if (jsonStr == null || jsonStr.isEmpty) return null; + final decoded = jsonDecode(jsonStr); + if (decoded is Map) return decoded; + if (decoded is Map) return decoded.cast(); + } catch (_) {} + return null; + } + + /// Persist payload to SharedPreferences + static Future _persistPayload( + String orderId, + Map payload, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('$_payloadKeyPrefix$orderId', jsonEncode(payload)); + } catch (_) {} + } + + /// Mark order as active in SharedPreferences + static Future _markAsActive(String orderId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final list = prefs.getStringList(_activeDeliveriesKey) ?? []; + if (!list.contains(orderId)) { + list.add(orderId); + await prefs.setStringList(_activeDeliveriesKey, list); + } + } catch (_) {} + } + + /// Fetch deliveries from API using v1 endpoint + static Future> _fetchDeliveriesFromApi(Uri uri) async { + try { + final res = await _httpClient + .get(uri) + .timeout(const Duration(seconds: 10)); + + if (res.statusCode >= 200 && res.statusCode < 300) { + final decoded = json.decode(res.body); + + final data = decoded is Map + ? (decoded['details'] ?? decoded['data'] ?? decoded) + : decoded; + + if (data is List) { + return data; + } + if (data is Map && data['items'] is List) { + return data['items'] as List; + } + + return []; + } + return []; + } catch (e) { + return []; + } + } + + /// Helper to pad numbers with leading zero + static String _pad(int n) => n.toString().padLeft(2, '0'); + + static Future _resolveLogStatus( + String orderId, + Map coords, + ) async { + try { + final last = await _loadLastLogLocation(orderId); + if (last != null) { + final lastLat = last.$1; + final lastLng = last.$2; + final currentLat = double.tryParse(coords['lat'] ?? '0') ?? 0; + final currentLng = double.tryParse(coords['lng'] ?? '0') ?? 0; + if (currentLat != 0 && + currentLng != 0 && + lastLat != 0 && + lastLng != 0) { + final distance = Geolocator.distanceBetween( + lastLat, + lastLng, + currentLat, + currentLng, + ); + if (distance <= _idleThresholdMeters) { + return 1; // idle + } + } + } + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG] logstatus error: $e'); + } + return 0; // moving + } + + static Future<(double, double)?> _loadLastLogLocation(String orderId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonStr = prefs.getString('last_log_loc_$orderId'); + if (jsonStr == null || jsonStr.isEmpty) return null; + final decoded = jsonDecode(jsonStr); + if (decoded is Map) { + final lat = double.tryParse('${decoded['lat']}') ?? 0; + final lng = double.tryParse('${decoded['lng']}') ?? 0; + if (lat != 0 && lng != 0) return (lat, lng); + } + } catch (_) {} + return null; + } + + static Future _persistLastLogLocation( + String orderId, + Map coords, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + 'last_log_loc_$orderId', + jsonEncode({'lat': coords['lat'], 'lng': coords['lng']}), + ); + } catch (_) {} + } + + // ---------------- Offline Queue Logic ---------------- + + static Future _flushOfflineLogs() async { + try { + final prefs = await SharedPreferences.getInstance(); + final List queue = prefs.getStringList(_offlineLogKey) ?? []; + if (queue.isEmpty) return; + + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Flushing ${queue.length} offline logs...', + ); + + final List remaining = []; + bool anySuccess = false; + + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createDeliveryLogLive + : ApiConstants.createDeliveryLogDev; + + for (final itemStr in queue) { + try { + final Map item = jsonDecode(itemStr); + final String orderId = item['orderId'] ?? ''; + final Map payload = Map.from( + item['payload'] ?? {}, + ); + + if (payload.isEmpty) continue; + + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Retrying for orderId: $orderId', + ); + + final result = await _logProvider + .createDeliveryLog(url, payload) + .timeout(const Duration(seconds: 8)); + + if (result != null) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Success for orderId: $orderId', + ); + anySuccess = true; + } else { + remaining.add(itemStr); + } + } catch (e) { + remaining.add(itemStr); + } + } + + if (anySuccess || remaining.length != queue.length) { + await prefs.setStringList(_offlineLogKey, remaining); + } + } catch (e) { + debugPrint('[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Flush error: $e'); + } + } + + static Future _saveToOfflineQueue( + String orderId, + Map payload, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + final List queue = prefs.getStringList(_offlineLogKey) ?? []; + + final item = jsonEncode({ + 'orderId': orderId, + 'payload': payload, + 'timestamp': DateTime.now().millisecondsSinceEpoch, + }); + + queue.add(item); + await prefs.setStringList(_offlineLogKey, queue); + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Saved to queue. Total: ${queue.length}', + ); + } catch (e) { + debugPrint( + '[ACTIVE_DELIVERY_LOG][BG][OFFLINE] Error saving to queue: $e', + ); + } + } + + // ---------------- Auto Shift End Logic ---------------- + + /// Check if shift has ended and trigger auto-break if needed + static Future checkShiftEnd() async { + try { + debugPrint('[AUTO_SHIFT_END] 🔍 Starting shift end check...'); + final prefs = await SharedPreferences.getInstance(); + + // 1. Check if currently On Duty + final int onduty = prefs.getInt('onduty') ?? 0; + debugPrint('[AUTO_SHIFT_END] 📊 Current onduty status: $onduty'); + if (onduty != 1) { + debugPrint('[AUTO_SHIFT_END] ⏭️ Rider not on duty, skipping check'); + return; // Already offline + } + + // 2. Get Shift Timings + final String startTimeStr = prefs.getString('starttime') ?? ''; + final String endTimeStr = prefs.getString('endtime') ?? ''; + debugPrint('[AUTO_SHIFT_END] ⏰ Shift times - Start: "$startTimeStr", End: "$endTimeStr"'); + + if (endTimeStr.isEmpty) { + debugPrint('[AUTO_SHIFT_END] ⚠️ No endtime found, cannot check shift end'); + return; + } + + // 3. Parse Times (Assumed format HH:mm:ss) + final now = DateTime.now(); + + final endParts = endTimeStr.split(':'); + if (endParts.length < 2) return; + + final int endHour = int.tryParse(endParts[0]) ?? 0; + final int endMinute = int.tryParse(endParts[1]) ?? 0; + final int endSecond = endParts.length > 2 ? (int.tryParse(endParts[2]) ?? 0) : 0; + + // Create DateTime for end time on TODAY + final DateTime endToday = DateTime( + now.year, + now.month, + now.day, + endHour, + endMinute, + endSecond, + ); + + bool isShiftOver = false; + + if (startTimeStr.isNotEmpty) { + final startParts = startTimeStr.split(':'); + if (startParts.length >= 2) { + final int startHour = int.tryParse(startParts[0]) ?? 0; + final int startMinute = int.tryParse(startParts[1]) ?? 0; + + // Create DateTime for start time on TODAY + final DateTime startToday = DateTime( + now.year, + now.month, + now.day, + startHour, + startMinute, + ); + + // Check for overnight shift (Start > End in 24-hour format, e.g. 22:00 to 06:00) + // This means shift crosses midnight + final double startVal = startHour + (startMinute / 60.0); + final double endVal = endHour + (endMinute / 60.0); + + if (startVal > endVal) { + // ✅ OVERNIGHT SHIFT (crosses midnight, e.g. 22:00 to 06:00) + // Shift is over if current time is AFTER end time today AND BEFORE start time today + // Example: End 06:00, Start 22:00 + // - Now 23:00 -> After 22:00 (start) -> Still in shift (ACTIVE) + // - Now 05:00 -> Before 06:00 (end) -> Still in shift from yesterday (ACTIVE) + // - Now 10:00 -> After 06:00 (end) AND Before 22:00 (start) -> Shift OVER + + debugPrint('[AUTO_SHIFT_END] 🌙 Overnight shift detected (Start: ${_pad(startHour)}:${_pad(startMinute)}, End: ${_pad(endHour)}:${_pad(endMinute)})'); + debugPrint('[AUTO_SHIFT_END] 📅 Current time: ${_pad(now.hour)}:${_pad(now.minute)}'); + + if (now.isAfter(endToday) && now.isBefore(startToday)) { + // We're in the gap period between end and start -> shift is OVER + isShiftOver = true; + debugPrint('[AUTO_SHIFT_END] ✅ Overnight shift: In gap period -> SHIFT OVER'); + } else { + // We're either before end (still in shift from yesterday) or after start (still in shift today) + isShiftOver = false; + debugPrint('[AUTO_SHIFT_END] ✅ Overnight shift: Still active'); + } + } else { + // ✅ NORMAL DAY SHIFT (e.g. 09:00 to 17:00, doesn't cross midnight) + // Shift is over if current time is AFTER end time + debugPrint('[AUTO_SHIFT_END] ☀️ Day shift detected (Start: ${_pad(startHour)}:${_pad(startMinute)}, End: ${_pad(endHour)}:${_pad(endMinute)})'); + debugPrint('[AUTO_SHIFT_END] 📅 Current time: ${_pad(now.hour)}:${_pad(now.minute)}, End time: ${_pad(endHour)}:${_pad(endMinute)}'); + + if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { + isShiftOver = true; + debugPrint('[AUTO_SHIFT_END] ✅ Day shift: Shift ended'); + } else { + isShiftOver = false; + debugPrint('[AUTO_SHIFT_END] ✅ Day shift: Still active'); + } + } + } else { + // Fallback if start time parse fails: assume day shift + debugPrint('[AUTO_SHIFT_END] ⚠️ Could not parse start time, assuming day shift'); + if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { + isShiftOver = true; + } + } + } else { + // Fallback if no start time: assume day shift + debugPrint('[AUTO_SHIFT_END] ⚠️ No start time provided, assuming day shift'); + if (now.isAfter(endToday) || now.isAtSameMomentAs(endToday)) { + isShiftOver = true; + } + } + + // 4. Trigger Auto End if Shift is Over + debugPrint('[AUTO_SHIFT_END] 📅 Time check result - isShiftOver: $isShiftOver, Current: ${_pad(now.hour)}:${_pad(now.minute)}, End: ${_pad(endHour)}:${_pad(endMinute)}'); + if (isShiftOver) { + debugPrint('[AUTO_SHIFT_END] ⏰ Shift ended (Start: $startTimeStr, End: $endTimeStr). Current: ${_pad(now.hour)}:${_pad(now.minute)}'); + await _autoEndShift(prefs); + } else { + debugPrint('[AUTO_SHIFT_END] ✅ Shift not ended yet, continuing...'); + } + } catch (e) { + debugPrint('[AUTO_SHIFT_END] ❌ Error checking shift end: $e'); + debugPrint('[AUTO_SHIFT_END] Stack trace: ${StackTrace.current}'); + } + } + + static Future _autoEndShift(SharedPreferences prefs) async { + try { + debugPrint( + '[AUTO_SHIFT_END] 🚀 Initiating auto-break and offline sequence...', + ); + + // 1. Get Required IDs + final int userid = prefs.getInt('userid') ?? prefs.getInt('userId') ?? 0; + final int partnerid = + prefs.getInt('partnerid') ?? prefs.getInt('partnerId') ?? 0; + final int shiftid = + prefs.getInt('shiftid') ?? prefs.getInt('shiftId') ?? 0; + final int logid = prefs.getInt('logid') ?? prefs.getInt('logId') ?? 0; + + if (userid == 0) { + debugPrint( + '[AUTO_SHIFT_END] ❌ Missing userid, cannot create break log', + ); + return; + } + + // 2. Get Location + final Map? coords = await _getCoordinatesWithFallback(); + final String lat = coords?['lat'] ?? '0'; + final String lng = coords?['lng'] ?? '0'; + + // 3. Prepare Break Log Payload + final now = DateTime.now(); + final int localBreakId = + (DateTime.now().millisecondsSinceEpoch % 900) + 100; // Random-ish ID + final String breakdate = + '${now.year}-${_pad(now.month)}-${_pad(now.day)} ${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; + final String breakstart = + '${_pad(now.hour)}:${_pad(now.minute)}:${_pad(now.second)}'; + + final payload = { + "breakid": localBreakId, + "logid": logid, + "breakdate": breakdate, + "userid": userid, + "partnerid": partnerid, + "shiftid": shiftid, + "breakstart": breakstart, + "breakend": "", + "breakhours": 0.0, + "latitude": lat, + "longitude": lng, + }; + + // 4. Call API to Create Break + final url = ApiConstants.mainRoute == 'live' + ? ApiConstants.createBreakRiderLogLive + : ApiConstants.createBreakRiderLogDev; + + debugPrint('[AUTO_SHIFT_END] Creating break log: $url'); + + // We use a separate provider instance or http call if needed, + // but _logProvider is for delivery logs. We need a generic post or use http directly. + // Since we don't have BreakRiderLogProvider here, we'll use http directly for simplicity and isolation. + + try { + final response = await _httpClient + .post( + Uri.parse(url), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ) + .timeout(const Duration(seconds: 10)); + + if (response.statusCode >= 200 && response.statusCode < 300) { + debugPrint('[AUTO_SHIFT_END] ✅ Break log created successfully'); + + // Parse response to get server break ID if needed, but mainly we just need to go offline + final body = jsonDecode(response.body); + final det = (body['details'] is Map) ? body['details'] : body; + final serverBreakId = det['breakid']; + + if (serverBreakId != null) { + await prefs.setInt( + 'breakId', + int.tryParse('$serverBreakId') ?? localBreakId, + ); + } + await prefs.setString('breakStart', breakstart); + await prefs.setInt('break_start_epoch', now.millisecondsSinceEpoch); + } else { + debugPrint( + '[AUTO_SHIFT_END] ⚠️ Failed to create break log: ${response.statusCode}', + ); + } + } catch (e) { + debugPrint('[AUTO_SHIFT_END] ❌ API Error: $e'); + // Even if API fails, we MUST go offline locally to prevent further issues + } + + // 5. Set Offline Locally + await prefs.setInt('onduty', 0); + await prefs.setBool('online', false); + + // 6. Update Rider Log (Set Duty = 0) + // We should also update the main rider log to say onduty=0 + final updateUrl = ApiConstants.mainRoute == 'live' + ? ApiConstants.updateRiderLogLive + : ApiConstants.updateRiderLogDev; + + final updatePayload = { + "userid": userid, + "onduty": 0, + "latitude": lat, + "longitude": lng, + }; + + try { + await _httpClient + .post( + Uri.parse(updateUrl), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode(updatePayload), + ) + .timeout(const Duration(seconds: 5)); + debugPrint('[AUTO_SHIFT_END] ✅ Rider status updated to Offline'); + } catch (_) {} + + debugPrint('[AUTO_SHIFT_END] 🏁 Auto-shift end sequence complete.'); + } catch (e) { + debugPrint('[AUTO_SHIFT_END] Critical error in _autoEndShift: $e'); + } + } + + /// Trigger proximity alert once per delivery when within threshold + static Future _checkProximityAlert( + Map order, + Map coords, + ) async { + try { + final deliveryId = + (order['deliveryid'] ?? order['orderid'] ?? '').toString(); + if (deliveryId.isEmpty) return; + + final dropLatRaw = + (order['droplat'] ?? order['DropLat'] ?? order['deliverylat'] ?? '') + .toString(); + final dropLngRaw = (order['droplon'] ?? + order['droplong'] ?? + order['DropLon'] ?? + order['DropLong'] ?? + order['deliverylong'] ?? + '') + .toString(); + final dropLat = double.tryParse(dropLatRaw) ?? 0.0; + final dropLng = double.tryParse(dropLngRaw) ?? 0.0; + if (dropLat == 0 || + dropLng == 0 || + dropLat.abs() > 90 || + dropLng.abs() > 180) { + return; + } + + final riderLat = double.tryParse(coords['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(coords['lng'] ?? '0') ?? 0.0; + if (riderLat == 0 || + riderLng == 0 || + riderLat.abs() > 90 || + riderLng.abs() > 180) { + return; + } + + final distanceMeters = Geolocator.distanceBetween( + dropLat, + dropLng, + riderLat, + riderLng, + ); + + if (distanceMeters <= _proximityThresholdMeters) { + final prefs = await SharedPreferences.getInstance(); + final alreadyAlerted = prefs.getBool('$_proximityAlertKeyPrefix$deliveryId') ?? false; + if (alreadyAlerted) { + debugPrint('[PROXIMITY] Already alerted for deliveryId=$deliveryId, skipping'); + return; + } + + await NotificationServce.showLocalNotification( + title: 'Near delivery location', + body: 'You\'ve reached your destination. Please update the status.', + ); + + final played = await _playProximityAudio(); + await prefs.setBool('$_proximityAlertKeyPrefix$deliveryId', true); + + debugPrint( + '[PROXIMITY] Alerted deliveryId=$deliveryId at ${distanceMeters.toStringAsFixed(1)}m ' + 'target=($dropLat,$dropLng) rider=($riderLat,$riderLng) played=$played', + ); + } else { + debugPrint( + '[PROXIMITY] Skipped alert for deliveryId=$deliveryId | distance=${distanceMeters.toStringAsFixed(1)}m', + ); + } + } catch (e) { + debugPrint('[PROXIMITY] Error sending alert: $e'); + } + } + + static Future _playProximityAudio() async { + try { + if (!_audioReady) { + await _proximityPlayer.setReleaseMode(ReleaseMode.stop); + await _proximityPlayer.setVolume(1.0); + _audioReady = true; + } + // Attempt to play bundled destination audio + await _proximityPlayer.stop(); + await _proximityPlayer.play(AssetSource('audio/destination.mp3')); + debugPrint('[PROXIMITY][AUDIO] Playing destination.mp3'); + return true; + } catch (e) { + debugPrint('[PROXIMITY][AUDIO] Error playing destination clip: $e'); + } + + // Fallback to TTS if audio fails + try { + // Initialize once + if (!_ttsReady) { + await _tts.setLanguage('en-US'); + await _tts.setSpeechRate(0.9); + await _tts.setVolume(1.0); + await _tts.setPitch(1.0); + _ttsReady = true; + } + // Speak without awaiting completion to avoid blocking + await _tts.speak('You have reached your destination. Please update the delivery status.'); + debugPrint('[PROXIMITY][TTS] Spoke destination prompt'); + return true; + } catch (e) { + debugPrint('[PROXIMITY][TTS] Error speaking prompt: $e'); + } + return false; + } +} diff --git a/lib/controllers/deliveries_controller.dart b/lib/controllers/deliveries_controller.dart index 4c200ac..f90fda8 100644 --- a/lib/controllers/deliveries_controller.dart +++ b/lib/controllers/deliveries_controller.dart @@ -1,2042 +1,1982 @@ -import 'dart:convert'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:get/get.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:http/http.dart' as http; - -import 'package:nearle/views/helpers/constants/apiconstants.dart'; -import 'package:nearle/providers/deliverylog/deliverylog_provider.dart'; -import 'dart:io'; -import 'package:minio/minio.dart'; -import 'package:minio/io.dart'; -import 'dart:math'; -import 'package:nearle/utils/kalman_filter.dart'; -import 'package:nearle/utils/mqtt_service.dart'; - -class DeliveriesController extends GetxController { - NearleKalmanFilter? _kf; - DateTime? _lastUpdateTime; - - final UpdateDeliveryProvider _updateProvider = UpdateDeliveryProvider(); - - bool _isSuccess(Map? resp) { - final status = resp?['status']; - if (status is bool) return status; - if (status is String) { - final v = status.toLowerCase(); - return v == 'true' || v == 'success' || v == 'ok' || v == 'accepted'; - } - return false; - } - - // UI flags similar to xpressrider for parity - final RxBool arrivedShimmer = false.obs; - final RxBool deliveredShimmer = false.obs; - final RxBool isPipEnabled = false.obs; - - // Distance tracking for PiP mode - final RxDouble deliverableDistance = 0.0.obs; // Distance in meters - - // Times captured in ISO-like format (yyyy-MM-dd HH:mm:ss) - String _arrivedTime = ''; - String _pickedTime = ''; - String _activeTime = ''; - String _deliveredTime = ''; - String _cancelledTime = ''; - - // Cached last known lat/lng from device - final RxString currentLat = '0'.obs; - final RxString currentLng = '0'.obs; - - // Bonus Points Tracking - final RxInt lastBonusPoints = 0.obs; - - // Trigger to refresh deliveries list across screens - final RxInt refreshTrigger = 0.obs; - - void triggerRefresh() { - refreshTrigger.value++; - } - - // ---------- UPLOAD IMAGE TO DO SPACES ---------- - Future uploadProofImage( - File imageFile, - String folderName, - int userId, - int deliveryId, - ) async { - try { - // final rng = Random(); // Unused - const String region = "sgp1"; - const String accessKey = "DO00NQER7N2FRYZAB2HR"; - const String secretKey = "nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0"; - const String bucketName = "nearle"; - // folderName will be "picked" or "delivered" - - // File name - final now = DateTime.now(); - final dateStr = - "${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}"; - final timeStr = - "${now.hour.toString().padLeft(2, '0')}${now.minute.toString().padLeft(2, '0')}${now.second.toString().padLeft(2, '0')}"; - final String fileName = '$folderName-$deliveryId-$dateStr-$timeStr.jpg'; - - // Object path inside the bucket - // final String objectPath = "$folderName/$fileName"; - final String objectPath = "support/$fileName"; - - // CDN URL you want - final String cdnUrl = "https://images.nearle.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"); - Get.snackbar("Error", "Image upload failed. Please try again."); - return null; - } - } - - // ---------------- Location helpers ---------------- - Future> _ensureLatLng(String lat, String lng) async { - String outLat = lat; - String outLng = lng; - - try { - final needsFetch = - (lat == '0' || lat.isEmpty || lng == '0' || lng.isEmpty); - - // Fast path: if we have valid coordinates, use them immediately - if (!needsFetch) return {'lat': outLat, 'lng': outLng}; - - // Reuse recently cached coordinates first if fresh (e.g. within 30s) - // For now just check if they exist to save time - if (currentLat.value.isNotEmpty && - currentLat.value != '0' && - currentLng.value.isNotEmpty && - currentLng.value != '0') { - return {'lat': currentLat.value, 'lng': currentLng.value}; - } - - final serviceEnabled = await Geolocator.isLocationServiceEnabled(); - if (!serviceEnabled) return {'lat': outLat, 'lng': outLng}; - - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - } - if (permission == LocationPermission.denied || - permission == LocationPermission.deniedForever) { - return {'lat': outLat, 'lng': outLng}; - } - - Position? pos; - - // 1. Try Last Known Position (Instant) - try { - pos = await Geolocator.getLastKnownPosition(); - } catch (_) {} - - // 2. If no last known, try current with a single balanced timeout - // Reduced complicated retry logic to one solid attempt - if (pos == null) { - try { - pos = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, // Better accuracy for deliveries - timeLimit: const Duration(seconds: 4), - ); - } catch (_) { - // Fallback to low accuracy if high fails quickly - try { - pos = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.low, - timeLimit: const Duration(seconds: 2), - ); - } catch (_) {} - } - } - - if (pos != null) { - final now = DateTime.now(); - double outLatDouble = pos.latitude; - double outLngDouble = pos.longitude; - - if (_kf == null) { - _kf = NearleKalmanFilter(lat: outLatDouble, lng: outLngDouble); - } else { - final double dt = _lastUpdateTime != null - ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 - : 30.0; - _kf!.predict(dt); - _kf!.update(outLatDouble, outLngDouble); - outLatDouble = _kf!.x[0]; - outLngDouble = _kf!.x[1]; - } - _lastUpdateTime = now; - - outLat = outLatDouble.toStringAsFixed(6); - outLng = outLngDouble.toStringAsFixed(6); - currentLat.value = outLat; - currentLng.value = outLng; - } - - return {'lat': outLat, 'lng': outLng}; - } catch (_) { - return {'lat': outLat, 'lng': outLng}; - } - } - - // Picked - Future updatePickedStatus({ - required int deliveryId, - required int orderHeaderId, - required int pickupLocationId, - String ridersLat = '0', - String ridersLng = '0', - String pickupLat = '0', - String pickupLng = '0', - String address = '', - String city = '', - String state = '', - String suburb = '', - String postcode = '', - String deliveryType = '', - String notes = '', - double actualKms = 0.0, - String? proofImage, // New parameter - }) async { - try { - final now = DateTime.now(); - _pickedTime = _formatDateTimeFull(now); - - final ll = await _ensureLatLng(ridersLat, ridersLng); - final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; - final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; - final pLat = double.tryParse(pickupLat) ?? 0.0; - final pLng = double.tryParse(pickupLng) ?? 0.0; - - // Geofence Check: Picked -> Pickup Location - final inFence = await _checkGeofence(pLat, pLng, rLat, rLng, 'Picked'); - if (!inFence) return false; - - // Save pickup location ONLY if it hasn't been saved yet (first pickup only) - final existingPickup = await _getPickupLocation(); - if (existingPickup['lat']!.isEmpty && - pickupLat != '0' && - pickupLng != '0') { - await _savePickupLocation(pickupLat, pickupLng); - } else { - debugPrint('[PICKED] Pickup location already exists, not overwriting'); - } - - // Calculate distance from rider location to pickup location if coordinates are available - double calculatedKms = actualKms; - if (actualKms == 0.0) { - try { - final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; - final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; - final pickLat = double.tryParse(pickupLat) ?? 0.0; - final pickLng = double.tryParse(pickupLng) ?? 0.0; - - if (riderLat != 0 && riderLng != 0 && pickLat != 0 && pickLng != 0) { - final distanceMeters = Geolocator.distanceBetween( - riderLat, - riderLng, - pickLat, - pickLng, - ); - calculatedKms = distanceMeters / 1000.0; - debugPrint( - '[PICKED] Calculated distance: ${calculatedKms.toStringAsFixed(2)} km from rider to pickup', - ); - } - } catch (e) { - debugPrint('[PICKED] Error calculating distance: $e'); - } - } - - final payload = { - 'deliveryid': deliveryId, - 'orderheaderid': orderHeaderId, - 'pickuplocationid': pickupLocationId, - 'orderstatus': 'picked', - 'pickuptime': _pickedTime, - 'riderslat': ll['lat'], - 'riderslon': ll['lng'], - 'deliverylat': '', - 'deliverylong': '', - 'actualkms': calculatedKms.toStringAsFixed(2), - 'deliveryamt': 0.0, - 'deliverytype': deliveryType, - 'address': address, - 'city': city, - 'state': state, - 'suburb': suburb, - 'postcode': postcode, - 'notes': notes, - 'pickupimage': proofImage ?? '', // Standard key - 'proofimage': proofImage ?? '', // Fallback/Alternative key just in case - }; - - debugPrint('[PICKED] Payload: $payload'); - - final url = _resolveUpdateUrl(); - final resp = await _updateProvider.updatePickedDelivery(payload, url); - final ok = _isSuccess(resp); - - if (!ok) { - debugPrint('[UPDATE][PICKED][FAILED] resp=${jsonEncode(resp)}'); - } else { - // --- MQTT LOGIC --- - NearleMqttService().publishLog('delivery_picked', payload); - } - return ok; - } catch (e) { - debugPrint('[UPDATE][PICKED][ERROR] $e'); - return false; - } - } - - // Delivered - Future updateDeliveredStatus({ - required int deliveryId, - required int orderHeaderId, - String ridersLat = '0', - String ridersLng = '0', - int deliveryLocationId = 0, - int smsDelivery = 0, - String pickupLat = '0', - String pickupLng = '0', - String deliveryLat = '0', - String deliveryLng = '0', - String notes = '', - double deliveryAmount = 0.0, - double actualKms = 0.0, - double collectionAmount = 0.0, - double collectedAmount = 0.0, - int collectionStatus = 0, - bool wasSkipped = false, - String orderId = '', // New parameter for timer/bonus logic - String? proofImage, // New parameter - }) async { - try { - deliveredShimmer.value = true; - // ✅ PARALLEL OPTIMIZATION: Start fetching Prefs immediately - final prefsFuture = SharedPreferences.getInstance(); - - final now = DateTime.now(); - _deliveredTime = _formatDateTimeFull(now); - - final ll = await _ensureLatLng(ridersLat, ridersLng); - final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; - final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; - final dLat = double.tryParse(deliveryLat) ?? 0.0; - final dLng = double.tryParse(deliveryLng) ?? 0.0; - - // Geofence Check: Delivered -> Delivery Location - final inFence = await _checkGeofence(dLat, dLng, rLat, rLng, 'Delivered'); - if (!inFence) { - deliveredShimmer.value = false; - return false; - } - - debugPrint( - '[DELIVERED] Rider GPS location: lat=${ll['lat']}, lng=${ll['lng']}', - ); - - // Wait for prefs to be ready - final prefs = await prefsFuture; - - // ---------------- RIDER TIME (minutes) ---------------- - int riderTimeMinutes = 0; - try { - final key = 'ridertime_start_$deliveryId'; - final startStr = prefs.getString(key); - if (startStr != null && startStr.isNotEmpty) { - final start = DateTime.tryParse(startStr); - if (start != null) { - final diff = now.difference(start).inSeconds / 60.0; - if (diff > 0) { - // Round: 2.7 -> 3, 2.5 -> 3, 2.4 -> 2, etc. - riderTimeMinutes = diff.round(); - } - } - } - debugPrint('[DELIVERED] riderTimeMinutes=$riderTimeMinutes'); - } catch (e) { - debugPrint('[DELIVERED] Error computing ridertime: $e'); - } - - // CRITICAL: Always calculate riderkms - even for small distances (10 meters = 0.01 km) - // This ensures riderkms is ALWAYS passed correctly, never null - double calculatedRiderKms = 0.0; - final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; - final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; - - // PRIORITY 1: Use cumulative distance tracked from active to delivered (most accurate) - // This is the actual distance the rider traveled, calculated from GPS points every 30 seconds - try { - final cumulativeKmStr = - prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? - ''; - if (cumulativeKmStr.isNotEmpty) { - final cumulativeKm = double.tryParse(cumulativeKmStr) ?? 0.0; - if (cumulativeKm > 0) { - calculatedRiderKms = cumulativeKm; - debugPrint( - '[DELIVERED] ✅ Using CUMULATIVE tracked distance: ${calculatedRiderKms.toStringAsFixed(4)} km', - ); - debugPrint( - '[DELIVERED] 📊 This is the actual distance traveled from active to delivered', - ); - } - } - } catch (e) { - debugPrint('[DELIVERED] Error getting cumulative distance: $e'); - } - - // PRIORITY 2: Only use actualKms if cumulative distance not available and it's greater than 0 - if (calculatedRiderKms == 0.0 && - actualKms > 0.0 && - riderLat != 0 && - riderLng != 0) { - calculatedRiderKms = actualKms; - debugPrint( - '[DELIVERED] Using provided actualKms: ${calculatedRiderKms.toStringAsFixed(2)} km', - ); - } - - // Always try to calculate distance if we have valid rider coordinates - // CRITICAL: Use Google Maps Directions API for accurate ROAD distance (not straight-line) - if (calculatedRiderKms == 0.0 && riderLat != 0 && riderLng != 0) { - try { - // Method 1: Try Start Location (Anti-Cheat) - most accurate - final startLoc = await _getDeliveryStartLocation(deliveryId); - final startLatStr = startLoc['lat'] ?? ''; - final startLngStr = startLoc['lng'] ?? ''; - - if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) { - final startLat = double.tryParse(startLatStr) ?? 0.0; - final startLng = double.tryParse(startLngStr) ?? 0.0; - if (startLat != 0 && startLng != 0) { - // Try Google Maps route distance first (accurate road distance) - final routeKm = await _getRouteDistanceKm( - startLat, - startLng, - riderLat, - riderLng, - ); - if (routeKm != null && routeKm > 0) { - calculatedRiderKms = routeKm; - debugPrint( - '[DELIVERED] ✅ Route distance (Start Location): ${calculatedRiderKms.toStringAsFixed(4)} km', - ); - } else { - // Fallback to straight-line if API fails - final distanceMeters = Geolocator.distanceBetween( - startLat, - startLng, - riderLat, - riderLng, - ); - calculatedRiderKms = distanceMeters / 1000.0; - debugPrint( - '[DELIVERED] ⚠️ Fallback straight-line (Start Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', - ); - } - } - } - - // Method 2: If start location failed, try provided pickup location - if (calculatedRiderKms == 0.0 && - pickupLat != '0' && - pickupLng != '0') { - final pickLat = double.tryParse(pickupLat) ?? 0.0; - final pickLng = double.tryParse(pickupLng) ?? 0.0; - if (pickLat != 0 && pickLng != 0) { - // Try Google Maps route distance first - final routeKm = await _getRouteDistanceKm( - pickLat, - pickLng, - riderLat, - riderLng, - ); - if (routeKm != null && routeKm > 0) { - calculatedRiderKms = routeKm; - debugPrint( - '[DELIVERED] ✅ Route distance (Pickup Location): ${calculatedRiderKms.toStringAsFixed(4)} km', - ); - } else { - // Fallback to straight-line - final distanceMeters = Geolocator.distanceBetween( - pickLat, - pickLng, - riderLat, - riderLng, - ); - calculatedRiderKms = distanceMeters / 1000.0; - debugPrint( - '[DELIVERED] ⚠️ Fallback straight-line (Pickup Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', - ); - } - } - } - - // Method 3: Fallback to saved pickup location - if (calculatedRiderKms == 0.0) { - final pickupLoc = await _getPickupLocation(); - final pickLat = double.tryParse(pickupLoc['lat'] ?? '') ?? 0.0; - final pickLng = double.tryParse(pickupLoc['lng'] ?? '') ?? 0.0; - if (pickLat != 0 && pickLng != 0) { - // Try Google Maps route distance first - final routeKm = await _getRouteDistanceKm( - pickLat, - pickLng, - riderLat, - riderLng, - ); - if (routeKm != null && routeKm > 0) { - calculatedRiderKms = routeKm; - debugPrint( - '[DELIVERED] ✅ Route distance (Saved Pickup): ${calculatedRiderKms.toStringAsFixed(4)} km', - ); - } else { - // Fallback to straight-line - final distanceMeters = Geolocator.distanceBetween( - pickLat, - pickLng, - riderLat, - riderLng, - ); - calculatedRiderKms = distanceMeters / 1000.0; - debugPrint( - '[DELIVERED] ⚠️ Fallback straight-line (Saved Pickup): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', - ); - } - } - } - - // Method 4: Final fallback - calculate from delivery location to current location - if (calculatedRiderKms == 0.0 && - deliveryLat != '0' && - deliveryLng != '0') { - final dLat = double.tryParse(deliveryLat) ?? 0.0; - final dLng = double.tryParse(deliveryLng) ?? 0.0; - if (dLat != 0 && dLng != 0) { - // Try Google Maps route distance first - final routeKm = await _getRouteDistanceKm( - dLat, - dLng, - riderLat, - riderLng, - ); - if (routeKm != null && routeKm > 0) { - calculatedRiderKms = routeKm; - debugPrint( - '[DELIVERED] ✅ Route distance (Delivery Location): ${calculatedRiderKms.toStringAsFixed(4)} km', - ); - } else { - // Fallback to straight-line - final distanceMeters = Geolocator.distanceBetween( - dLat, - dLng, - riderLat, - riderLng, - ); - calculatedRiderKms = distanceMeters / 1000.0; - debugPrint( - '[DELIVERED] ⚠️ Fallback straight-line (Delivery Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', - ); - } - } - } - } catch (e) { - debugPrint('[DELIVERED] Error calculating distance: $e'); - } - } - - // CRITICAL: Ensure riderkms is never 0 or null - use minimum value if calculation failed - // Even 10 meters should be shown (0.01 km) - if (calculatedRiderKms == 0.0) { - debugPrint( - '[DELIVERED] ⚠️ WARNING: Could not calculate distance, using minimum 0.01 km (10 meters)', - ); - calculatedRiderKms = 0.01; // Minimum 10 meters - } - - debugPrint( - '[DELIVERED] Final riderkms: ${calculatedRiderKms.toStringAsFixed(4)} km', - ); - - // ---- BONUS POINTS LOGIC ---- - int bonusPoints = 0; - if (orderId.isNotEmpty) { - try { - final endKey = 'eta_endtime_$orderId'; - final endSeconds = prefs.getInt(endKey); - - if (endSeconds != null) { - final currentSeconds = - DateTime.now().millisecondsSinceEpoch ~/ 1000; - // If delivered before or at the end time, give bonus points - if (currentSeconds <= endSeconds) { - // Bonus points = riderkms rounded - bonusPoints = calculatedRiderKms.round(); - debugPrint( - '[DELIVERED] 🏆 ON TIME! Bonus Points: $bonusPoints (RiderKms: $calculatedRiderKms)', - ); - } else { - debugPrint( - '[DELIVERED] ⏳ LATE! No Bonus Points. (Deadline: $endSeconds, Now: $currentSeconds)', - ); - } - } else { - debugPrint('[DELIVERED] No ETA timer found for bonus logic.'); - } - } catch (e) { - debugPrint('[DELIVERED] Error calculating bonus points: $e'); - } - } - - final double riderChargeRate = await _getRiderChargeRate(); - - // ---- SKIP PENALTY CHECK ---- - // If the rider has exceeded skip limits in the last 3 hours, forfeit bonus points - final skipStatus = await checkSkipStatus(); - if (skipStatus['penalty'] == true) { - if (bonusPoints > 0) { - debugPrint('[DELIVERED] ⚠️ Bonus points ($bonusPoints) forfeited due to skip penalty.'); - bonusPoints = 0; - } - } - - final double riderChargesAmountRaw = - riderChargeRate > 0 && calculatedRiderKms > 0 - ? calculatedRiderKms * riderChargeRate - : 0.0; - // Ensure ridercharges is sent with 2 decimal places (Decimal(10,2) style) - final double riderChargesAmount = double.parse( - riderChargesAmountRaw.toStringAsFixed(2), - ); - - // CRITICAL: Always set deliverytime to current time right before creating payload - // This ensures deliverytime is ALWAYS passed when confirming delivery, no matter what - // Setting it here (right before payload) ensures it's the exact time of confirmation - final currentTime = DateTime.now(); - final currentDeliveryTime = _formatDateTimeFull(currentTime); - _deliveredTime = currentDeliveryTime; // Update the instance variable too - - debugPrint('[DELIVERED] Setting deliverytime to: $currentDeliveryTime'); - - // CRITICAL: Ensure riderkms is always a valid string, never null or empty - final riderKmsString = calculatedRiderKms > 0 - ? calculatedRiderKms.toStringAsFixed( - 4, - ) // Use 4 decimals to show even 10 meters (0.0100 km) - : '0.0100'; // Fallback minimum (10 meters) - - // CRITICAL: Validate coordinates before creating payload - NEVER send '0' or null - final ridersLatStr = ll['lat'] ?? '0'; - final ridersLngStr = ll['lng'] ?? '0'; - final ridersLatDouble = double.tryParse(ridersLatStr) ?? 0.0; - final ridersLngDouble = double.tryParse(ridersLngStr) ?? 0.0; - - if (ridersLatDouble == 0 || - ridersLngDouble == 0 || - ridersLatDouble.abs() > 90 || - ridersLngDouble.abs() > 180) { - debugPrint( - '[DELIVERED] ❌ ERROR: Invalid rider coordinates (lat=$ridersLatStr, lng=$ridersLngStr) - cannot proceed', - ); - deliveredShimmer.value = false; - return false; // Don't send invalid coordinates - } - - // Validate delivery coordinates - final deliveryLatDouble = double.tryParse(deliveryLat) ?? 0.0; - final deliveryLngDouble = double.tryParse(deliveryLng) ?? 0.0; - if (deliveryLatDouble == 0 || - deliveryLngDouble == 0 || - deliveryLatDouble.abs() > 90 || - deliveryLngDouble.abs() > 180) { - debugPrint( - '[DELIVERED] ❌ ERROR: Invalid delivery coordinates (lat=$deliveryLat, lng=$deliveryLng) - cannot proceed', - ); - deliveredShimmer.value = false; - return false; // Don't send invalid coordinates - } - - debugPrint( - '[DELIVERED] ✅ Validated coordinates - Rider: ($ridersLatStr, $ridersLngStr), Delivery: ($deliveryLat, $deliveryLng)', - ); - - final payload = { - 'deliveryid': deliveryId, - 'orderheaderid': orderHeaderId, - 'deliverylocationid': deliveryLocationId, - 'orderstatus': 'delivered', - 'deliveredtime': currentDeliveryTime, - 'deliverytime': - currentDeliveryTime, // ALWAYS pass current time - required field, set right before payload - 'smsdelivery': smsDelivery, - 'riderslat': ridersLatStr, // Guaranteed non-zero and valid - 'riderslon': ridersLngStr, // Guaranteed non-zero and valid - 'raw_latitude': ll['raw_lat'] ?? '0', - 'raw_longitude': ll['raw_lng'] ?? '0', - 'velocity_lat': ll['velocity_lat'] ?? '0', - 'velocity_lng': ll['velocity_lng'] ?? '0', - 'speed': ll['speed'] ?? '0', - 'heading': ll['heading'] ?? '0', - 'pickuplat': pickupLat, - 'pickuplong': pickupLng, - 'deliverylat': deliveryLat, // Guaranteed non-zero and valid - 'deliverylong': deliveryLng, // Guaranteed non-zero and valid - 'riderkms': - riderKmsString, // ALWAYS pass riderkms - never null, shows even 10 meters (0.0100 km) - 'ridercharges': riderChargesAmount, - 'deliveryamt': deliveryAmount, - 'collectionamt': collectionAmount, - 'collectedamt': collectedAmount, - 'collectionstatus': collectionStatus, - 'ridertime': riderTimeMinutes, - 'notes': notes, - 'wasskipped': wasSkipped, - 'bonuspts': bonusPoints, // ✅ ADDED BONUS POINTS - 'dropimage': proofImage ?? '', // Include in payload - }; - - - // Update observable for UI - lastBonusPoints.value = bonusPoints; - - debugPrint( - '[DELIVERED] Payload includes deliverytime: $currentDeliveryTime', - ); - debugPrint('[DELIVERED] Payload includes riderkms: $riderKmsString km'); - debugPrint('[DELIVERED] Payload includes bonuspts: $bonusPoints'); - - final url = _resolveUpdateUrl(); - final resp = await _updateProvider.updateDelivery(payload, url); - final ok = _isSuccess(resp); - - // Save current RIDER GPS location as last delivery location for next delivery - if (ok) { - final actualLat = ll['lat'] ?? '0'; - final actualLng = ll['lng'] ?? '0'; - if (actualLat != '0' && actualLng != '0') { - await _saveLastDeliveryLocation(actualLat, actualLng); - } - - // CRITICAL: Clean up cumulative distance tracking after delivery is completed - try { - final cumulativeKm = - prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? - ''; - if (cumulativeKm.isNotEmpty) { - debugPrint( - '[DELIVERED] 📊 Final cumulative distance used: $cumulativeKm km', - ); - } - // Clean up tracking data - await prefs.remove('delivery_tracking_${deliveryId}_lastLat'); - await prefs.remove('delivery_tracking_${deliveryId}_lastLng'); - await prefs.remove('delivery_tracking_${deliveryId}_cumulativeKm'); - await prefs.remove('delivery_tracking_${deliveryId}_lastUpdateMs'); - await prefs.remove('ridertime_start_$deliveryId'); - await prefs.remove('delivery_proximity_alerted_$deliveryId'); - - final activeTracking = prefs.getStringList('active_tracking_delivery_ids') ?? []; - if (activeTracking.contains(deliveryId.toString())) { - activeTracking.remove(deliveryId.toString()); - await prefs.setStringList('active_tracking_delivery_ids', activeTracking); - } - - // ✅ Clean up ETA timer - if (orderId.isNotEmpty) { - await prefs.remove('eta_endtime_$orderId'); - } - debugPrint( - '[DELIVERED] 🧹 Cleaned up distance tracking for deliveryId: $deliveryId', - ); - } catch (e) { - debugPrint('[DELIVERED] Error cleaning up tracking: $e'); - } - } - - // Reset foreground delivery notification flag - await setNotificationSent(false); - deliveredShimmer.value = false; - - if (!ok) { - debugPrint('[UPDATE][DELIVERED][FAILED] resp=${jsonEncode(resp)}'); - } else { - // --- MQTT LOGIC --- - NearleMqttService().publishLog('delivery_completed', payload); - } - return ok; - } catch (e) { - debugPrint('[UPDATE][DELIVERED][ERROR] $e'); - deliveredShimmer.value = false; - return false; - } - } - - // ---------------- Date/Time helpers ---------------- - String _two(int n) => n.toString().padLeft(2, '0'); - - String _formatDateTimeFull(DateTime dt) { - final y = dt.year.toString(); - final m = _two(dt.month); - final d = _two(dt.day); - final hh = _two(dt.hour); - final mm = _two(dt.minute); - final ss = _two(dt.second); - return "$y-$m-$d $hh:$mm:$ss"; - } - - String _resolveUpdateUrl() { - return ApiConstants.mainRoute == 'live' - ? ApiConstants.updateDeliveryLive - : ApiConstants.updateDeliveryDev; - } - - // ---------------- SharedPrefs helpers ---------------- - Future setNotificationSent(bool value) async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setBool('notificationSent', value); - } catch (_) {} - } - - Future getLogSecondsOrDefault([int fallback = 60]) async { - try { - final prefs = await SharedPreferences.getInstance(); - return prefs.getInt('logSeconds') ?? fallback; - } catch (_) { - return fallback; - } - } - - Future persistDeliveryLogSnapshot( - List> logs, - ) async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('deliveryLog', jsonEncode(logs)); - } catch (_) {} - } - - // ---------------- Geofencing helpers ---------------- - Future _getDeliveryRadius() async { - try { - final prefs = await SharedPreferences.getInstance(); - return prefs.getInt('deliveryradius') ?? 100; - } catch (_) { - return 100; - } - } - - // Helper method to show snackbar reliably in both debug and release builds - void _showErrorSnackbar( - String title, - String message, { - Color? bgColor, - int seconds = 4, - int retryCount = 0, - }) { - // Prevent infinite recursion - if (retryCount > 3) { - print('[GEOFENCE] Max retries reached for snackbar. Title: $title'); - return; - } - - try { - final context = Get.key.currentContext; - - // Get bottom safe area padding - final double bottomSafePadding = context != null - ? MediaQuery.of(context).padding.bottom - : 0; - - Get.snackbar( - title, - message, - backgroundColor: bgColor ?? Colors.red, - colorText: Colors.white, - duration: Duration(seconds: seconds), - snackPosition: SnackPosition.BOTTOM, - isDismissible: true, - shouldIconPulse: true, - margin: EdgeInsets.fromLTRB( - 12, - 0, - 12, - 12 + bottomSafePadding, // SafeArea bottom - ), - borderRadius: 10, - maxWidth: 400, - forwardAnimationCurve: Curves.easeOutBack, - reverseAnimationCurve: Curves.easeInBack, - ); - } catch (e) { - print( - '[GEOFENCE] Error showing snackbar (attempt ${retryCount + 1}): $e', - ); - print( - '[GEOFENCE] GetX context available: ${Get.key.currentContext != null}', - ); - - Future.delayed(const Duration(milliseconds: 400), () { - try { - Get.snackbar( - title, - message, - backgroundColor: bgColor ?? Colors.red, - colorText: Colors.white, - snackPosition: SnackPosition.BOTTOM, - ); - } catch (e2) { - print('[GEOFENCE] Retry snackbar failed: $e2'); - - if (retryCount < 2) { - Future.delayed(const Duration(milliseconds: 300), () { - _showErrorSnackbar( - title, - message, - bgColor: bgColor, - seconds: seconds, - retryCount: retryCount + 1, - ); - }); - } - } - }); - } - } - - Future _checkGeofence( - double targetLat, - double targetLng, - double currentLat, - double currentLng, - String action, - ) async { - // Validate coordinates - ensure they are valid GPS coordinates - final bool hasValidTarget = - targetLat != 0 && - targetLng != 0 && - targetLat.abs() <= 90 && - targetLng.abs() <= 180; - final bool hasValidCurrent = - currentLat != 0 && - currentLng != 0 && - currentLat.abs() <= 90 && - currentLng.abs() <= 180; - - if (!hasValidTarget || !hasValidCurrent) { - // If coordinates are missing or invalid, show error and block - if (kDebugMode) { - debugPrint( - '[GEOFENCE] Missing or invalid coordinates for $action. Target: ($targetLat, $targetLng), Current: ($currentLat, $currentLng)', - ); - } - - // Show warning snackbar using helper method - _showErrorSnackbar( - 'Location Warning', - 'Missing coordinates. Proceeding with update.', - bgColor: Colors.orange, - seconds: 3, - ); - return true; // Allow update to proceed despite missing coords - } - - try { - final radius = await _getDeliveryRadius(); - final distance = Geolocator.distanceBetween( - targetLat, - targetLng, - currentLat, - currentLng, - ); - final distanceKm = distance / 1000.0; - final radiusKm = radius / 1000.0; - final distanceMeters = distance; - - if (kDebugMode) { - debugPrint( - '[GEOFENCE] Action: $action | Target: ($targetLat, $targetLng) | Current: ($currentLat, $currentLng) | Distance: ${distanceMeters.toStringAsFixed(1)}m (${distanceKm.toStringAsFixed(3)}km) | Radius: ${radius}m (${radiusKm.toStringAsFixed(3)}km)', - ); - } - - if (distance > radius) { - // Show error snackbar with clear message using helper method - _showErrorSnackbar( - 'Location Error', - 'You are too far from the location to mark as $action.\nDistance: ${distanceMeters.toStringAsFixed(0)} m (${distanceKm.toStringAsFixed(2)} km)\nRequired: Within $radius m', - seconds: 5, - ); - return false; - } - return true; - } catch (e) { - if (kDebugMode) { - debugPrint('[GEOFENCE] Error calculating distance: $e'); - } - // On error, show warning but allow (fail-safe) - _showErrorSnackbar( - 'Location Warning', - 'Unable to verify distance. Proceeding with caution.', - bgColor: Colors.orange, - seconds: 3, - ); - return true; // Allow on error (fail-safe) - } - } - - // Get last delivery location (for calculating riderkms between deliveries) - Future> _getLastDeliveryLocation() async { - try { - final prefs = await SharedPreferences.getInstance(); - final lat = prefs.getString('lastDeliveryLat') ?? ''; - final lng = prefs.getString('lastDeliveryLng') ?? ''; - if (lat.isNotEmpty && lng.isNotEmpty) { - return {'lat': lat, 'lng': lng}; - } - } catch (_) {} - return {'lat': '', 'lng': ''}; - } - - // Save last delivery location (rider's actual GPS location, not destination) - Future _saveLastDeliveryLocation(String lat, String lng) async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('lastDeliveryLat', lat); - await prefs.setString('lastDeliveryLng', lng); - debugPrint('[TRACKING] Saved last delivery location: lat=$lat, lng=$lng'); - } catch (e) { - debugPrint('[TRACKING] Error saving last delivery location: $e'); - } - } - - // Save start location for a specific delivery (when "Start Navigation" is slid) - Future _saveDeliveryStartLocation( - int deliveryId, - String lat, - String lng, - ) async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('delivery_start_${deliveryId}_lat', lat); - await prefs.setString('delivery_start_${deliveryId}_lng', lng); - debugPrint( - '[TRACKING] Saved start location for delivery $deliveryId: lat=$lat, lng=$lng', - ); - } catch (e) { - debugPrint('[TRACKING] Error saving delivery start location: $e'); - } - } - - // Get start location for a specific delivery - Future> _getDeliveryStartLocation(int deliveryId) async { - try { - final prefs = await SharedPreferences.getInstance(); - final lat = prefs.getString('delivery_start_${deliveryId}_lat') ?? ''; - final lng = prefs.getString('delivery_start_${deliveryId}_lng') ?? ''; - if (lat.isNotEmpty && lng.isNotEmpty) { - return {'lat': lat, 'lng': lng}; - } - } catch (_) {} - return {'lat': '', 'lng': ''}; - } - - // Get pickup location (for first delivery riderkms calculation) - Future> _getPickupLocation() async { - try { - final prefs = await SharedPreferences.getInstance(); - final lat = prefs.getString('pickupLat') ?? ''; - final lng = prefs.getString('pickupLng') ?? ''; - if (lat.isNotEmpty && lng.isNotEmpty) { - return {'lat': lat, 'lng': lng}; - } - } catch (_) {} - return {'lat': '', 'lng': ''}; - } - - // Google Maps API Key - static const String _googleMapsApiKey = - 'AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q'; - - // Get route distance from Google Maps Directions API (accurate road distance) - // Returns distance in kilometers, or null if API fails - Future _getRouteDistanceKm( - double originLat, - double originLng, - double destLat, - double destLng, - ) async { - try { - // 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; - } - - // Google Maps Directions API endpoint - final url = Uri.parse( - 'https://maps.googleapis.com/maps/api/directions/json' - '?origin=$originLat,$originLng' - '&destination=$destLat,$destLng' - '&key=$_googleMapsApiKey' - '&units=metric' - '&mode=driving', // Use driving mode for accurate road distance - ); - - debugPrint( - '[ROUTE] Requesting route distance from ($originLat, $originLng) to ($destLat, $destLng)', - ); - - final response = await http - .get(url) - .timeout( - const Duration(seconds: 5), - onTimeout: () { - debugPrint('[ROUTE] API timeout'); - return http.Response('', 408); - }, - ); - - if (response.statusCode == 200) { - final data = json.decode(response.body); - - if (data['status'] == 'OK' && - data['routes'] != null && - (data['routes'] as List).isNotEmpty) { - final route = (data['routes'] as List).first; - final legs = route['legs'] as List?; - - if (legs != null && legs.isNotEmpty) { - double totalDistanceMeters = 0.0; - for (final leg in legs) { - final distance = leg['distance'] as Map?; - if (distance != null && distance['value'] != null) { - totalDistanceMeters += (distance['value'] as num).toDouble(); - } - } - - final distanceKm = totalDistanceMeters / 1000.0; - debugPrint( - '[ROUTE] ✅ Route distance: ${distanceKm.toStringAsFixed(4)} km (${totalDistanceMeters.toStringAsFixed(0)} meters)', - ); - return distanceKm; - } - } else { - debugPrint('[ROUTE] ⚠️ API returned status: ${data['status']}'); - } - } else { - debugPrint('[ROUTE] ⚠️ API error: ${response.statusCode}'); - } - } catch (e) { - debugPrint('[ROUTE] ❌ Error getting route distance: $e'); - } - - return null; // Return null if API fails - will fallback to straight-line - } - - // Save pickup location - Future _savePickupLocation(String lat, String lng) async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('pickupLat', lat); - await prefs.setString('pickupLng', lng); - debugPrint('[TRACKING] Saved pickup location: lat=$lat, lng=$lng'); - } catch (e) { - debugPrint('[TRACKING] Error saving pickup location: $e'); - } - } - - Future _getRiderChargeRate() async { - try { - final prefs = await SharedPreferences.getInstance(); - // Prefer new fuelcharge key; fall back to legacy firstmilecharge if needed - Object? raw = prefs.get('fuelcharge'); - raw ??= prefs.get('firstmilecharge'); - if (raw is double) return raw; - if (raw is int) return raw.toDouble(); - if (raw is String) { - return double.tryParse(raw) ?? 0.0; - } - } catch (_) {} - return 0.0; - } - - // Clear delivery tracking (when all deliveries are done or starting fresh) - Future clearDeliveryTracking() async { - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.remove('lastDeliveryLat'); - await prefs.remove('lastDeliveryLng'); - await prefs.remove('pickupLat'); - await prefs.remove('pickupLng'); - debugPrint('[TRACKING] Cleared all delivery tracking data'); - } catch (e) { - debugPrint('[TRACKING] Error clearing delivery tracking: $e'); - } - } - - // ---------------- Status Updates ---------------- - - // Accepted - Future updateAcceptedStatus({ - required int deliveryId, - required int orderHeaderId, - String ridersLat = '0', - String ridersLng = '0', - String notes = '', - }) async { - try { - final ll = await _ensureLatLng(ridersLat, ridersLng); - final acceptedTime = _formatDateTimeFull(DateTime.now()); - final payload = { - 'deliveryid': deliveryId, - 'orderheaderid': orderHeaderId, - 'orderstatus': 'accepted', - 'acceptedtime': acceptedTime, - 'riderslat': ll['lat'], - 'riderslon': ll['lng'], - 'raw_latitude': ll['raw_lat'] ?? '0', - 'raw_longitude': ll['raw_lng'] ?? '0', - 'velocity_lat': ll['velocity_lat'] ?? '0', - 'velocity_lng': ll['velocity_lng'] ?? '0', - 'speed': ll['speed'] ?? '0', - 'heading': ll['heading'] ?? '0', - 'deliverylat': '', - 'deliverylong': '', - 'actualkms': '', - 'deliveryamt': 0.0, - 'notes': notes, - }; - - final url = _resolveUpdateUrl(); - final resp = await _updateProvider.updateDelivery(payload, url); - final ok = _isSuccess(resp); - if (!ok) { - // Log entire response for debugging - try { - debugPrint('[UPDATE][ACCEPTED][FAILED] resp=${jsonEncode(resp)}'); - } catch (_) {} - } - return ok; - } catch (e) { - debugPrint('[UPDATE][ACCEPTED][ERROR] $e'); - return false; - } - } - - // Active (start navigation) - Future updateActiveStatus({ - required int deliveryId, - required int orderHeaderId, - String ridersLat = '0', - String ridersLng = '0', - String notes = '', - String orderId = '', - }) async { - try { - final now = DateTime.now(); - _activeTime = _formatDateTimeFull(now); - final prefs = await SharedPreferences.getInstance(); - String startTime = prefs.getString('delivery_starttime_$deliveryId') ?? ''; - - if (startTime.isEmpty) { - startTime = _formatDateTimeFull(now); - } else { - debugPrint('[UPDATE][ACTIVE] Using preserved starttime: $startTime'); - } - - // Ensure we never send 0/0 for rider location. - Map ll = await _ensureLatLng(ridersLat, ridersLng); - if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') { - try { - final last = await Geolocator.getLastKnownPosition(); - if (last != null) { - ll = { - 'lat': last.latitude.toStringAsFixed(6), - 'lng': last.longitude.toStringAsFixed(6), - }; - } - } catch (_) {} - } - if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') { - try { - final pos = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.low, - timeLimit: const Duration(seconds: 3), - ); - ll = { - 'lat': pos.latitude.toStringAsFixed(6), - 'lng': pos.longitude.toStringAsFixed(6), - }; - } catch (_) {} - } - - // If we still don't have a valid location, bail out to avoid sending 0/0. - if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') { - debugPrint('[UPDATE][ACTIVE] No valid rider coordinates; aborting'); - return false; - } - final payload = { - 'deliveryid': deliveryId, - 'orderheaderid': orderHeaderId, - 'orderstatus': 'active', - 'activetime': _activeTime, - 'starttime': startTime, // Add starttime when status changes to active - 'activelat': ll['lat'], - 'activelon': ll['lng'], - 'riderslat': ll['lat'], - 'riderslon': ll['lng'], - 'raw_latitude': ll['raw_lat'] ?? '0', - 'raw_longitude': ll['raw_lng'] ?? '0', - 'velocity_lat': ll['velocity_lat'] ?? '0', - 'velocity_lng': ll['velocity_lng'] ?? '0', - 'speed': ll['speed'] ?? '0', - 'heading': ll['heading'] ?? '0', - 'deliverylat': '', - 'deliverylong': '', - 'actualkms': '', - 'deliveryamt': 0.0, - 'notes': notes, - }; - - final url = _resolveUpdateUrl(); - final resp = await _updateProvider.updateActiveDelivery(payload, url); - final ok = _isSuccess(resp); - - if (ok) { - final lat = ll['lat'] ?? '0'; - final lng = ll['lng'] ?? '0'; - if (lat != '0' && lng != '0') { - await _saveDeliveryStartLocation(deliveryId, lat, lng); - } - - // Save starttime to SharedPreferences for delivery logs (using deliveryId as key) - // This will be retrieved when creating delivery log payloads - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('delivery_starttime_$deliveryId', startTime); - if (orderId.isNotEmpty) { - await prefs.setString('current_riding_order_id', orderId); - } - await prefs.setString('current_riding_delivery_id', deliveryId.toString()); - debugPrint( - '[UPDATE][ACTIVE] Saved starttime: $startTime for deliveryId: $deliveryId', - ); - - // CRITICAL: Initialize cumulative distance tracking when delivery becomes active - // Reset any previous tracking data and set initial location - await prefs.setString('delivery_tracking_${deliveryId}_lastLat', lat); - await prefs.setString('delivery_tracking_${deliveryId}_lastLng', lng); - await prefs.setString( - 'delivery_tracking_${deliveryId}_cumulativeKm', - '0.0', - ); - await prefs.setInt( - 'delivery_tracking_${deliveryId}_lastUpdateMs', - DateTime.now().millisecondsSinceEpoch, - ); - - final activeTracking = prefs.getStringList('active_tracking_delivery_ids') ?? []; - if (!activeTracking.contains(deliveryId.toString())) { - activeTracking.add(deliveryId.toString()); - await prefs.setStringList('active_tracking_delivery_ids', activeTracking); - } - - debugPrint( - '[UPDATE][ACTIVE] 🚀 Initialized distance tracking for deliveryId: $deliveryId at ($lat, $lng)', - ); - } catch (e) { - debugPrint('[UPDATE][ACTIVE] Error saving starttime: $e'); - } - } else { - debugPrint('[UPDATE][ACTIVE][FAILED] resp=${jsonEncode(resp)}'); - } - - return ok; - } catch (e) { - debugPrint('[UPDATE][ACTIVE][ERROR] $e'); - return false; - } - } - - // Arrived - Future updateArrivedStatus({ - required int deliveryId, - required int orderHeaderId, - String ridersLat = '0', - String ridersLng = '0', - String pickupLat = '0', - String pickupLng = '0', - String notes = '', - }) async { - // START LOADING IMMEDIATELY for better UX - arrivedShimmer.value = true; - try { - final ll = await _ensureLatLng(ridersLat, ridersLng); - final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; - final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; - final pLat = double.tryParse(pickupLat) ?? 0.0; - final pLng = double.tryParse(pickupLng) ?? 0.0; - - // Geofence Check: Arrived -> Pickup Location - final inFence = await _checkGeofence(pLat, pLng, rLat, rLng, 'Arrived'); - if (!inFence) { - arrivedShimmer.value = false; - return false; - } - - final now = DateTime.now(); - _arrivedTime = _formatDateTimeFull(now); - - final payload = { - 'deliveryid': deliveryId, - 'orderheaderid': orderHeaderId, - 'orderstatus': 'arrived', - 'arrivaltime': _arrivedTime, - 'riderslat': ll['lat'], - 'riderslon': ll['lng'], - 'raw_latitude': ll['raw_lat'] ?? '0', - 'raw_longitude': ll['raw_lng'] ?? '0', - 'velocity_lat': ll['velocity_lat'] ?? '0', - 'velocity_lng': ll['velocity_lng'] ?? '0', - 'speed': ll['speed'] ?? '0', - 'heading': ll['heading'] ?? '0', - 'deliverylat': '', - 'deliverylong': '', - 'actualkms': '', - 'deliveryamt': 0.0, - 'notes': notes, - }; - - final url = _resolveUpdateUrl(); - final resp = await _updateProvider.updateArrivedDelivery(payload, url); - final ok = _isSuccess(resp); - - // Stop shimmer immediately after response - arrivedShimmer.value = false; - - if (!ok) { - debugPrint('[UPDATE][ARRIVED][FAILED] resp=${jsonEncode(resp)}'); - } - return ok; - } catch (e) { - debugPrint('[UPDATE][ARRIVED][ERROR] $e'); - arrivedShimmer.value = false; - return false; - } - } - - // Rejected (remove from queue) - Future updateRejectedStatus({ - required int deliveryId, - required int orderHeaderId, - String ridersLat = '0', - String ridersLng = '0', - String notes = '', - }) async { - try { - final ll = await _ensureLatLng(ridersLat, ridersLng); - final payload = { - 'deliveryid': deliveryId, - 'orderheaderid': orderHeaderId, - 'orderstatus': 'rejected', - 'riderslat': ll['lat'], - 'riderslon': ll['lng'], - 'raw_latitude': ll['raw_lat'] ?? '0', - 'raw_longitude': ll['raw_lng'] ?? '0', - 'velocity_lat': ll['velocity_lat'] ?? '0', - 'velocity_lng': ll['velocity_lng'] ?? '0', - 'speed': ll['speed'] ?? '0', - 'heading': ll['heading'] ?? '0', - 'deliverylat': '', - 'deliverylong': '', - 'actualkms': '', - 'deliveryamt': 0.0, - 'notes': notes, - }; - - final url = _resolveUpdateUrl(); - final resp = await _updateProvider.updateDelivery(payload, url); - final ok = _isSuccess(resp); - - if (!ok) { - debugPrint('[UPDATE][REJECTED][FAILED] resp=${jsonEncode(resp)}'); - } - return ok; - } catch (e) { - debugPrint('[UPDATE][REJECTED][ERROR] $e'); - return false; - } - } - - // Cancelled (delivery cancelled by rider) - Future updateCancelledStatus({ - required int deliveryId, - required int orderHeaderId, - String ridersLat = '0', - String ridersLng = '0', - String pickupLat = '0', - String pickupLng = '0', - String deliveryLat = '0', - String deliveryLng = '0', - String notes = '', - double actualKms = 0.0, - bool wasSkipped = false, - }) async { - try { - final now = DateTime.now(); - _cancelledTime = _formatDateTimeFull(now); - - final ll = await _ensureLatLng(ridersLat, ridersLng); - - final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; - final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; - final dLat = double.tryParse(deliveryLat) ?? 0.0; - final dLng = double.tryParse(deliveryLng) ?? 0.0; - - // ✅ SPECIAL CASE: Skip geofence check for "Incorrect Location" cancellation - // When user cancels due to incorrect location, we don't verify range - final bool isIncorrectLocation = notes.toLowerCase().contains( - 'incorrect location', - ); - - if (isIncorrectLocation) { - debugPrint( - '[CANCELLED] ⚠️ Skipping geofence check - Reason: Incorrect Location', - ); - } else { - // Geofence Check: Cancelled -> Delivery Location (for all other reasons) - final inFence = await _checkGeofence( - dLat, - dLng, - riderLat, - riderLng, - 'Cancelled', - ); - if (!inFence) { - debugPrint( - '[CANCELLED] ❌ Geofence check failed - Rider not in range', - ); - return false; - } - } - - // PRIORITY 1: Use cumulative distance tracked from active to cancelled (most accurate) - // This is the actual distance the rider traveled, calculated from GPS points every 30 seconds - double calculatedKms = 0.0; - try { - final prefs = await SharedPreferences.getInstance(); - final cumulativeKmStr = - prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? - ''; - if (cumulativeKmStr.isNotEmpty) { - final cumulativeKm = double.tryParse(cumulativeKmStr) ?? 0.0; - if (cumulativeKm > 0) { - calculatedKms = cumulativeKm; - debugPrint( - '[CANCELLED] ✅ Using CUMULATIVE tracked distance: ${calculatedKms.toStringAsFixed(4)} km', - ); - debugPrint( - '[CANCELLED] 📊 This is the actual distance traveled from active to cancelled', - ); - } - } - } catch (e) { - debugPrint('[CANCELLED] Error getting cumulative distance: $e'); - } - - // PRIORITY 2: Fallback to actualKms if cumulative not available - if (calculatedKms == 0.0) { - calculatedKms = actualKms; - } - - if (wasSkipped && calculatedKms == 0.0) { - try { - final lastDelivery = await _getLastDeliveryLocation(); - final lastLat = double.tryParse(lastDelivery['lat'] ?? '') ?? 0.0; - final lastLng = double.tryParse(lastDelivery['lng'] ?? '') ?? 0.0; - if (lastLat != 0 && lastLng != 0 && riderLat != 0 && riderLng != 0) { - final distanceMeters = Geolocator.distanceBetween( - lastLat, - lastLng, - riderLat, - riderLng, - ); - calculatedKms = distanceMeters / 1000.0; - debugPrint( - '[CANCELLED] Skipped delivery - rider travelled ${calculatedKms.toStringAsFixed(2)} km from last stop to cancellation point', - ); - } - } catch (e) { - debugPrint('[CANCELLED] Error calculating skipped distance: $e'); - } - } - - if (calculatedKms == 0.0) { - try { - // ANTI-CHEAT: Try to get the locked "Start Location" for this delivery - final startLoc = await _getDeliveryStartLocation(deliveryId); - final startLatStr = startLoc['lat'] ?? ''; - final startLngStr = startLoc['lng'] ?? ''; - - if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) { - final startLat = double.tryParse(startLatStr) ?? 0.0; - final startLng = double.tryParse(startLngStr) ?? 0.0; - if (startLat != 0 && - startLng != 0 && - riderLat != 0 && - riderLng != 0) { - // Try Google Maps route distance first (accurate road distance) - final routeKm = await _getRouteDistanceKm( - startLat, - startLng, - riderLat, - riderLng, - ); - if (routeKm != null && routeKm > 0) { - calculatedKms = routeKm; - debugPrint( - '[CANCELLED] ✅ Route distance (Anti-Cheat): ${calculatedKms.toStringAsFixed(4)} km from Start Location to cancellation point', - ); - } else { - // Fallback to straight-line if API fails - final distanceMeters = Geolocator.distanceBetween( - startLat, - startLng, - riderLat, - riderLng, - ); - calculatedKms = distanceMeters / 1000.0; - debugPrint( - '[CANCELLED] ⚠️ Fallback straight-line (Anti-Cheat): ${calculatedKms.toStringAsFixed(4)} km', - ); - } - } - } else { - // Fallback to Pickup -> Current - final pickLat = double.tryParse(pickupLat) ?? 0.0; - final pickLng = double.tryParse(pickupLng) ?? 0.0; - if (pickLat != 0 && - pickLng != 0 && - riderLat != 0 && - riderLng != 0) { - // Try Google Maps route distance first - final routeKm = await _getRouteDistanceKm( - pickLat, - pickLng, - riderLat, - riderLng, - ); - if (routeKm != null && routeKm > 0) { - calculatedKms = routeKm; - debugPrint( - '[CANCELLED] ✅ Route distance: ${calculatedKms.toStringAsFixed(4)} km from pickup to cancellation point', - ); - } else { - // Fallback to straight-line if API fails - final distanceMeters = Geolocator.distanceBetween( - pickLat, - pickLng, - riderLat, - riderLng, - ); - calculatedKms = distanceMeters / 1000.0; - debugPrint( - '[CANCELLED] ⚠️ Fallback straight-line: ${calculatedKms.toStringAsFixed(4)} km', - ); - } - } - } - } catch (e) { - debugPrint('[CANCELLED] Error calculating distance: $e'); - } - } - - final double riderChargeRate = await _getRiderChargeRate(); - final double riderChargesAmountRaw = - riderChargeRate > 0 && calculatedKms > 0 - ? calculatedKms * riderChargeRate - : 0.0; - final double riderChargesAmount = double.parse( - riderChargesAmountRaw.toStringAsFixed(2), - ); - if (riderChargeRate > 0) { - debugPrint( - '[CANCELLED] Applying rider charge rate $riderChargeRate => ridercharges ${riderChargesAmount.toStringAsFixed(2)}', - ); - } - - // CRITICAL: Clean up cumulative distance tracking after cancellation - try { - final prefs = await SharedPreferences.getInstance(); - final cumulativeKm = - prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? - ''; - if (cumulativeKm.isNotEmpty) { - debugPrint( - '[CANCELLED] 📊 Final cumulative distance used: $cumulativeKm km', - ); - } - // Clean up tracking data - await prefs.remove('delivery_tracking_${deliveryId}_lastLat'); - await prefs.remove('delivery_tracking_${deliveryId}_lastLng'); - await prefs.remove('delivery_tracking_${deliveryId}_cumulativeKm'); - await prefs.remove('delivery_tracking_${deliveryId}_lastUpdateMs'); - await prefs.remove('delivery_proximity_alerted_$deliveryId'); - - final activeTracking = prefs.getStringList('active_tracking_delivery_ids') ?? []; - if (activeTracking.contains(deliveryId.toString())) { - activeTracking.remove(deliveryId.toString()); - await prefs.setStringList('active_tracking_delivery_ids', activeTracking); - } - - debugPrint( - '[CANCELLED] 🧹 Cleaned up distance tracking for deliveryId: $deliveryId', - ); - } catch (e) { - debugPrint('[CANCELLED] Error cleaning up tracking: $e'); - } - - // CRITICAL: Validate coordinates before creating payload - NEVER send '0' or null - final ridersLatStr = ll['lat'] ?? '0'; - final ridersLngStr = ll['lng'] ?? '0'; - final ridersLatDouble = double.tryParse(ridersLatStr) ?? 0.0; - final ridersLngDouble = double.tryParse(ridersLngStr) ?? 0.0; - - if (ridersLatDouble == 0 || - ridersLngDouble == 0 || - ridersLatDouble.abs() > 90 || - ridersLngDouble.abs() > 180) { - debugPrint( - '[CANCELLED] ❌ ERROR: Invalid rider coordinates (lat=$ridersLatStr, lng=$ridersLngStr) - cannot proceed', - ); - return false; // Don't send invalid coordinates - } - - debugPrint( - '[CANCELLED] ✅ Validated coordinates - Rider: ($ridersLatStr, $ridersLngStr)', - ); - - final payload = { - 'deliveryid': deliveryId, - 'orderheaderid': orderHeaderId, - 'orderstatus': 'cancelled', - 'canceltime': _cancelledTime, - 'riderslat': ridersLatStr, // Guaranteed non-zero and valid - 'riderslon': ridersLngStr, // Guaranteed non-zero and valid - 'raw_latitude': ll['raw_lat'] ?? '0', - 'raw_longitude': ll['raw_lng'] ?? '0', - 'velocity_lat': ll['velocity_lat'] ?? '0', - 'velocity_lng': ll['velocity_lng'] ?? '0', - 'speed': ll['speed'] ?? '0', - 'heading': ll['heading'] ?? '0', - 'deliverylat': '', - 'deliverylong': '', - 'riderkms': calculatedKms.toStringAsFixed(2), - 'ridercharges': riderChargesAmount, - 'deliveryamt': 0.0, - 'notes': notes, - }; - - final url = _resolveUpdateUrl(); - final resp = await _updateProvider.updateDelivery(payload, url); - final ok = _isSuccess(resp); - - if (ok) { - // Update last delivery location to current location so next order starts here - final actualLat = ll['lat'] ?? '0'; - final actualLng = ll['lng'] ?? '0'; - if (actualLat != '0' && actualLng != '0') { - await _saveLastDeliveryLocation(actualLat, actualLng); - } - } - - if (!ok) { - debugPrint('[UPDATE][CANCELLED][FAILED] resp=${jsonEncode(resp)}'); - } - return ok; - } catch (e) { - debugPrint('[UPDATE][CANCELLED][ERROR] $e'); - return false; - } - } - - // Skipped (delivery skipped by rider) - Future updateSkippedStatus({ - required int deliveryId, - required int orderHeaderId, - String ridersLat = '0', - String ridersLng = '0', - String notes = '', - }) async { - try { - final ll = await _ensureLatLng(ridersLat, ridersLng); - final skippedTime = _formatDateTimeFull(DateTime.now()); - final payload = { - 'deliveryid': deliveryId, - 'orderheaderid': orderHeaderId, - 'orderstatus': 'skipped', - 'skippedtime': skippedTime, - 'riderslat': ll['lat'], - 'riderslon': ll['lng'], - 'deliverylat': '', - 'deliverylong': '', - 'actualkms': '', - 'deliveryamt': 0.0, - 'notes': notes, - }; - - // Calculate distance for skipped order (Anti-Cheat) - double calculatedKms = 0.0; - try { - final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; - final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; - - if (riderLat != 0 && riderLng != 0) { - // Try Start Location first - final startLoc = await _getDeliveryStartLocation(deliveryId); - final startLatStr = startLoc['lat'] ?? ''; - final startLngStr = startLoc['lng'] ?? ''; - - if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) { - final startLat = double.tryParse(startLatStr) ?? 0.0; - final startLng = double.tryParse(startLngStr) ?? 0.0; - if (startLat != 0 && startLng != 0) { - // Try Google Maps route distance first (accurate road distance) - final routeKm = await _getRouteDistanceKm( - startLat, - startLng, - riderLat, - riderLng, - ); - if (routeKm != null && routeKm > 0) { - calculatedKms = routeKm; - debugPrint( - '[SKIPPED] ✅ Route distance (Start Location): ${calculatedKms.toStringAsFixed(4)} km', - ); - } else { - // Fallback to straight-line if API fails - final distanceMeters = Geolocator.distanceBetween( - startLat, - startLng, - riderLat, - riderLng, - ); - calculatedKms = distanceMeters / 1000.0; - debugPrint( - '[SKIPPED] ⚠️ Fallback straight-line (Start Location): ${calculatedKms.toStringAsFixed(4)} km', - ); - } - } - } else { - // Fallback to Last Delivery Location - final lastDelivery = await _getLastDeliveryLocation(); - final lastLat = double.tryParse(lastDelivery['lat'] ?? '') ?? 0.0; - final lastLng = double.tryParse(lastDelivery['lng'] ?? '') ?? 0.0; - if (lastLat != 0 && lastLng != 0) { - // Try Google Maps route distance first - final routeKm = await _getRouteDistanceKm( - lastLat, - lastLng, - riderLat, - riderLng, - ); - if (routeKm != null && routeKm > 0) { - calculatedKms = routeKm; - debugPrint( - '[SKIPPED] ✅ Route distance (Last Delivery): ${calculatedKms.toStringAsFixed(4)} km', - ); - } else { - // Fallback to straight-line if API fails - final distanceMeters = Geolocator.distanceBetween( - lastLat, - lastLng, - riderLat, - riderLng, - ); - calculatedKms = distanceMeters / 1000.0; - debugPrint( - '[SKIPPED] ⚠️ Fallback straight-line (Last Delivery): ${calculatedKms.toStringAsFixed(4)} km', - ); - } - } - } - } - } catch (e) { - debugPrint('[SKIPPED] Error calculating distance: $e'); - } - - if (calculatedKms > 0) { - payload['riderkms'] = calculatedKms.toStringAsFixed(2); - debugPrint('[SKIPPED] Calculated riderkms: ${payload['riderkms']}'); - } - - final url = _resolveUpdateUrl(); - final resp = await _updateProvider.updateDelivery(payload, url); - final ok = _isSuccess(resp); - - if (ok) { - // Update last delivery location to current location so next order starts here - final actualLat = ll['lat'] ?? '0'; - final actualLng = ll['lng'] ?? '0'; - if (actualLat != '0' && actualLng != '0') { - await _saveLastDeliveryLocation(actualLat, actualLng); - } - } - - if (!ok) { - debugPrint('[UPDATE][SKIPPED][FAILED] resp=${jsonEncode(resp)}'); - } - return ok; - } catch (e) { - debugPrint('[UPDATE][SKIPPED][ERROR] $e'); - return false; - } - } - - /// Check if there are active deliveries - /// Returns true ONLY if has_live_deliveries is true (verified by API) - /// This ensures PiP is only enabled when there are actually active deliveries - Future hasActiveDeliveries() async { - try { - final prefs = await SharedPreferences.getInstance(); - final hasLive = prefs.getBool('has_live_deliveries') ?? false; - - // ✅ CRITICAL: Only return true if has_live_deliveries is true - // Don't rely on active_delivery_order_id alone - it may be stale - // The API sets has_live_deliveries based on actual order status = "active" - if (!hasLive) { - // Clear stale active_delivery_order_id if has_live_deliveries is false - final activeOrderId = prefs.getString('active_delivery_order_id'); - if (activeOrderId != null && activeOrderId.isNotEmpty) { - debugPrint( - '[DELIVERIES_CONTROLLER] Clearing stale active_delivery_order_id: $activeOrderId (no active deliveries)', - ); - await prefs.remove('active_delivery_order_id'); - } - } - - debugPrint( - '[DELIVERIES_CONTROLLER] hasActiveDeliveries: $hasLive (has_live_deliveries from API)', - ); - return hasLive; - } catch (e) { - debugPrint( - '[DELIVERIES_CONTROLLER] Error checking active deliveries: $e', - ); - return false; - } - } - - // ---------------- Skip Penalty Logic ---------------- - - /// Checks the current skip count and penalty status within the 3-hour window. - /// Resets the window if 3 hours have passed since the first skip. - Future> checkSkipStatus() async { - try { - final prefs = await SharedPreferences.getInstance(); - final now = DateTime.now(); - final startStr = prefs.getString('skip_window_start'); - int count = prefs.getInt('skip_count') ?? 0; - bool penalty = prefs.getBool('skip_penalty_active') ?? false; - - if (startStr != null) { - final start = DateTime.tryParse(startStr); - if (start != null) { - final diff = now.difference(start); - if (diff.inHours >= 3) { - debugPrint('[SKIP_LOGIC] 3-hour window expired (elapsed: ${diff.inMinutes}m). Resetting skips.'); - // Reset window - count = 0; - penalty = false; - await prefs.remove('skip_window_start'); - await prefs.remove('skip_count'); - await prefs.remove('skip_penalty_active'); - } - } else { - // Invalid date string, reset - await prefs.remove('skip_window_start'); - } - } - - return {'count': count, 'penalty': penalty}; - } catch (e) { - debugPrint('[SKIP_LOGIC] Error checking status: $e'); - return {'count': 0, 'penalty': false}; - } - } - - /// Registers a skip action. - /// Increments skip count and sets penalty if specified. - /// Starts 3-hour window if not already active. - Future registerSkip({bool applyPenalty = false}) async { - try { - final prefs = await SharedPreferences.getInstance(); - final now = DateTime.now(); - - // Ensure we are working with fresh/current state - await checkSkipStatus(); - - // Re-fetch after potential reset - int count = prefs.getInt('skip_count') ?? 0; - String? startStr = prefs.getString('skip_window_start'); - - if (startStr == null) { - // Start new window - await prefs.setString('skip_window_start', now.toIso8601String()); - debugPrint('[SKIP_LOGIC] Starting new 3-hour skip window.'); - } - - count++; - await prefs.setInt('skip_count', count); - - if (applyPenalty) { - await prefs.setBool('skip_penalty_active', true); - debugPrint('[SKIP_LOGIC] Penalty activated for this session.'); - } - - debugPrint('[SKIP_LOGIC] Skip registered. Count: $count, Penalty: $applyPenalty'); - } catch (e) { - debugPrint('[SKIP_LOGIC] Error registering skip: $e'); - } - } -} - +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:geolocator/geolocator.dart'; + +import 'package:nearle/views/helpers/constants/apiconstants.dart'; +import 'package:nearle/providers/deliverylog/deliverylog_provider.dart'; +import 'dart:io'; +import 'package:minio/minio.dart'; +import 'package:minio/io.dart'; +import 'dart:math'; +import 'package:nearle/utils/kalman_filter.dart'; +import 'package:nearle/utils/mqtt_service.dart'; +import 'package:nearle/services/maps/nearle_map.dart' show LatLng; +import 'package:nearle/services/maps/routing_service.dart'; + +class DeliveriesController extends GetxController { + NearleKalmanFilter? _kf; + DateTime? _lastUpdateTime; + + final UpdateDeliveryProvider _updateProvider = UpdateDeliveryProvider(); + + bool _isSuccess(Map? resp) { + final status = resp?['status']; + if (status is bool) return status; + if (status is String) { + final v = status.toLowerCase(); + return v == 'true' || v == 'success' || v == 'ok' || v == 'accepted'; + } + return false; + } + + // UI flags similar to xpressrider for parity + final RxBool arrivedShimmer = false.obs; + final RxBool deliveredShimmer = false.obs; + final RxBool isPipEnabled = false.obs; + + // Distance tracking for PiP mode + final RxDouble deliverableDistance = 0.0.obs; // Distance in meters + + // Times captured in ISO-like format (yyyy-MM-dd HH:mm:ss) + String _arrivedTime = ''; + String _pickedTime = ''; + String _activeTime = ''; + String _deliveredTime = ''; + String _cancelledTime = ''; + + // Cached last known lat/lng from device + final RxString currentLat = '0'.obs; + final RxString currentLng = '0'.obs; + + // Bonus Points Tracking + final RxInt lastBonusPoints = 0.obs; + + // Trigger to refresh deliveries list across screens + final RxInt refreshTrigger = 0.obs; + + void triggerRefresh() { + refreshTrigger.value++; + } + + // ---------- UPLOAD IMAGE TO DO SPACES ---------- + Future uploadProofImage( + File imageFile, + String folderName, + int userId, + int deliveryId, + ) async { + try { + // final rng = Random(); // Unused + const String region = "sgp1"; + const String accessKey = "DO00NQER7N2FRYZAB2HR"; + const String secretKey = "nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0"; + const String bucketName = "nearle"; + // folderName will be "picked" or "delivered" + + // File name + final now = DateTime.now(); + final dateStr = + "${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}"; + final timeStr = + "${now.hour.toString().padLeft(2, '0')}${now.minute.toString().padLeft(2, '0')}${now.second.toString().padLeft(2, '0')}"; + final String fileName = '$folderName-$deliveryId-$dateStr-$timeStr.jpg'; + + // Object path inside the bucket + // final String objectPath = "$folderName/$fileName"; + final String objectPath = "support/$fileName"; + + // CDN URL you want + final String cdnUrl = "https://images.nearle.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"); + Get.snackbar("Error", "Image upload failed. Please try again."); + return null; + } + } + + // ---------------- Location helpers ---------------- + Future> _ensureLatLng(String lat, String lng) async { + String outLat = lat; + String outLng = lng; + + try { + final needsFetch = + (lat == '0' || lat.isEmpty || lng == '0' || lng.isEmpty); + + // Fast path: if we have valid coordinates, use them immediately + if (!needsFetch) return {'lat': outLat, 'lng': outLng}; + + // Reuse recently cached coordinates first if fresh (e.g. within 30s) + // For now just check if they exist to save time + if (currentLat.value.isNotEmpty && + currentLat.value != '0' && + currentLng.value.isNotEmpty && + currentLng.value != '0') { + return {'lat': currentLat.value, 'lng': currentLng.value}; + } + + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return {'lat': outLat, 'lng': outLng}; + + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + return {'lat': outLat, 'lng': outLng}; + } + + Position? pos; + + // 1. Try Last Known Position (Instant) + try { + pos = await Geolocator.getLastKnownPosition(); + } catch (_) {} + + // 2. If no last known, try current with a single balanced timeout + // Reduced complicated retry logic to one solid attempt + if (pos == null) { + try { + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.high, // Better accuracy for deliveries + timeLimit: const Duration(seconds: 4), + ); + } catch (_) { + // Fallback to low accuracy if high fails quickly + try { + pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.low, + timeLimit: const Duration(seconds: 2), + ); + } catch (_) {} + } + } + + if (pos != null) { + final now = DateTime.now(); + double outLatDouble = pos.latitude; + double outLngDouble = pos.longitude; + + if (_kf == null) { + _kf = NearleKalmanFilter(lat: outLatDouble, lng: outLngDouble); + } else { + final double dt = _lastUpdateTime != null + ? now.difference(_lastUpdateTime!).inMilliseconds / 1000.0 + : 30.0; + _kf!.predict(dt); + _kf!.update(outLatDouble, outLngDouble); + outLatDouble = _kf!.x[0]; + outLngDouble = _kf!.x[1]; + } + _lastUpdateTime = now; + + outLat = outLatDouble.toStringAsFixed(6); + outLng = outLngDouble.toStringAsFixed(6); + currentLat.value = outLat; + currentLng.value = outLng; + } + + return {'lat': outLat, 'lng': outLng}; + } catch (_) { + return {'lat': outLat, 'lng': outLng}; + } + } + + // Picked + Future updatePickedStatus({ + required int deliveryId, + required int orderHeaderId, + required int pickupLocationId, + String ridersLat = '0', + String ridersLng = '0', + String pickupLat = '0', + String pickupLng = '0', + String address = '', + String city = '', + String state = '', + String suburb = '', + String postcode = '', + String deliveryType = '', + String notes = '', + double actualKms = 0.0, + String? proofImage, // New parameter + }) async { + try { + final now = DateTime.now(); + _pickedTime = _formatDateTimeFull(now); + + final ll = await _ensureLatLng(ridersLat, ridersLng); + final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final pLat = double.tryParse(pickupLat) ?? 0.0; + final pLng = double.tryParse(pickupLng) ?? 0.0; + + // Geofence Check: Picked -> Pickup Location + final inFence = await _checkGeofence(pLat, pLng, rLat, rLng, 'Picked'); + if (!inFence) return false; + + // Save pickup location ONLY if it hasn't been saved yet (first pickup only) + final existingPickup = await _getPickupLocation(); + if (existingPickup['lat']!.isEmpty && + pickupLat != '0' && + pickupLng != '0') { + await _savePickupLocation(pickupLat, pickupLng); + } else { + debugPrint('[PICKED] Pickup location already exists, not overwriting'); + } + + // Calculate distance from rider location to pickup location if coordinates are available + double calculatedKms = actualKms; + if (actualKms == 0.0) { + try { + final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final pickLat = double.tryParse(pickupLat) ?? 0.0; + final pickLng = double.tryParse(pickupLng) ?? 0.0; + + if (riderLat != 0 && riderLng != 0 && pickLat != 0 && pickLng != 0) { + final distanceMeters = Geolocator.distanceBetween( + riderLat, + riderLng, + pickLat, + pickLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[PICKED] Calculated distance: ${calculatedKms.toStringAsFixed(2)} km from rider to pickup', + ); + } + } catch (e) { + debugPrint('[PICKED] Error calculating distance: $e'); + } + } + + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'pickuplocationid': pickupLocationId, + 'orderstatus': 'picked', + 'pickuptime': _pickedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': calculatedKms.toStringAsFixed(2), + 'deliveryamt': 0.0, + 'deliverytype': deliveryType, + 'address': address, + 'city': city, + 'state': state, + 'suburb': suburb, + 'postcode': postcode, + 'notes': notes, + 'pickupimage': proofImage ?? '', // Standard key + 'proofimage': proofImage ?? '', // Fallback/Alternative key just in case + }; + + debugPrint('[PICKED] Payload: $payload'); + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updatePickedDelivery(payload, url); + final ok = _isSuccess(resp); + + if (!ok) { + debugPrint('[UPDATE][PICKED][FAILED] resp=${jsonEncode(resp)}'); + } else { + // --- MQTT LOGIC --- + NearleMqttService().publishLog('delivery_picked', payload); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][PICKED][ERROR] $e'); + return false; + } + } + + // Delivered + Future updateDeliveredStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + int deliveryLocationId = 0, + int smsDelivery = 0, + String pickupLat = '0', + String pickupLng = '0', + String deliveryLat = '0', + String deliveryLng = '0', + String notes = '', + double deliveryAmount = 0.0, + double actualKms = 0.0, + double collectionAmount = 0.0, + double collectedAmount = 0.0, + int collectionStatus = 0, + bool wasSkipped = false, + String orderId = '', // New parameter for timer/bonus logic + String? proofImage, // New parameter + }) async { + try { + deliveredShimmer.value = true; + // ✅ PARALLEL OPTIMIZATION: Start fetching Prefs immediately + final prefsFuture = SharedPreferences.getInstance(); + + final now = DateTime.now(); + _deliveredTime = _formatDateTimeFull(now); + + final ll = await _ensureLatLng(ridersLat, ridersLng); + final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final dLat = double.tryParse(deliveryLat) ?? 0.0; + final dLng = double.tryParse(deliveryLng) ?? 0.0; + + // Geofence Check: Delivered -> Delivery Location + final inFence = await _checkGeofence(dLat, dLng, rLat, rLng, 'Delivered'); + if (!inFence) { + deliveredShimmer.value = false; + return false; + } + + debugPrint( + '[DELIVERED] Rider GPS location: lat=${ll['lat']}, lng=${ll['lng']}', + ); + + // Wait for prefs to be ready + final prefs = await prefsFuture; + + // ---------------- RIDER TIME (minutes) ---------------- + int riderTimeMinutes = 0; + try { + final key = 'ridertime_start_$deliveryId'; + final startStr = prefs.getString(key); + if (startStr != null && startStr.isNotEmpty) { + final start = DateTime.tryParse(startStr); + if (start != null) { + final diff = now.difference(start).inSeconds / 60.0; + if (diff > 0) { + // Round: 2.7 -> 3, 2.5 -> 3, 2.4 -> 2, etc. + riderTimeMinutes = diff.round(); + } + } + } + debugPrint('[DELIVERED] riderTimeMinutes=$riderTimeMinutes'); + } catch (e) { + debugPrint('[DELIVERED] Error computing ridertime: $e'); + } + + // CRITICAL: Always calculate riderkms - even for small distances (10 meters = 0.01 km) + // This ensures riderkms is ALWAYS passed correctly, never null + double calculatedRiderKms = 0.0; + final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + + // PRIORITY 1: Use cumulative distance tracked from active to delivered (most accurate) + // This is the actual distance the rider traveled, calculated from GPS points every 30 seconds + try { + final cumulativeKmStr = + prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? + ''; + if (cumulativeKmStr.isNotEmpty) { + final cumulativeKm = double.tryParse(cumulativeKmStr) ?? 0.0; + if (cumulativeKm > 0) { + calculatedRiderKms = cumulativeKm; + debugPrint( + '[DELIVERED] ✅ Using CUMULATIVE tracked distance: ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + debugPrint( + '[DELIVERED] 📊 This is the actual distance traveled from active to delivered', + ); + } + } + } catch (e) { + debugPrint('[DELIVERED] Error getting cumulative distance: $e'); + } + + // PRIORITY 2: Only use actualKms if cumulative distance not available and it's greater than 0 + if (calculatedRiderKms == 0.0 && + actualKms > 0.0 && + riderLat != 0 && + riderLng != 0) { + calculatedRiderKms = actualKms; + debugPrint( + '[DELIVERED] Using provided actualKms: ${calculatedRiderKms.toStringAsFixed(2)} km', + ); + } + + // Always try to calculate distance if we have valid rider coordinates + // CRITICAL: Use Google Maps Directions API for accurate ROAD distance (not straight-line) + if (calculatedRiderKms == 0.0 && riderLat != 0 && riderLng != 0) { + try { + // Method 1: Try Start Location (Anti-Cheat) - most accurate + final startLoc = await _getDeliveryStartLocation(deliveryId); + final startLatStr = startLoc['lat'] ?? ''; + final startLngStr = startLoc['lng'] ?? ''; + + if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) { + final startLat = double.tryParse(startLatStr) ?? 0.0; + final startLng = double.tryParse(startLngStr) ?? 0.0; + if (startLat != 0 && startLng != 0) { + // Try Google Maps route distance first (accurate road distance) + final routeKm = await _getRouteDistanceKm( + startLat, + startLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedRiderKms = routeKm; + debugPrint( + '[DELIVERED] ✅ Route distance (Start Location): ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + startLat, + startLng, + riderLat, + riderLng, + ); + calculatedRiderKms = distanceMeters / 1000.0; + debugPrint( + '[DELIVERED] ⚠️ Fallback straight-line (Start Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + + // Method 2: If start location failed, try provided pickup location + if (calculatedRiderKms == 0.0 && + pickupLat != '0' && + pickupLng != '0') { + final pickLat = double.tryParse(pickupLat) ?? 0.0; + final pickLng = double.tryParse(pickupLng) ?? 0.0; + if (pickLat != 0 && pickLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + pickLat, + pickLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedRiderKms = routeKm; + debugPrint( + '[DELIVERED] ✅ Route distance (Pickup Location): ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line + final distanceMeters = Geolocator.distanceBetween( + pickLat, + pickLng, + riderLat, + riderLng, + ); + calculatedRiderKms = distanceMeters / 1000.0; + debugPrint( + '[DELIVERED] ⚠️ Fallback straight-line (Pickup Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + + // Method 3: Fallback to saved pickup location + if (calculatedRiderKms == 0.0) { + final pickupLoc = await _getPickupLocation(); + final pickLat = double.tryParse(pickupLoc['lat'] ?? '') ?? 0.0; + final pickLng = double.tryParse(pickupLoc['lng'] ?? '') ?? 0.0; + if (pickLat != 0 && pickLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + pickLat, + pickLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedRiderKms = routeKm; + debugPrint( + '[DELIVERED] ✅ Route distance (Saved Pickup): ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line + final distanceMeters = Geolocator.distanceBetween( + pickLat, + pickLng, + riderLat, + riderLng, + ); + calculatedRiderKms = distanceMeters / 1000.0; + debugPrint( + '[DELIVERED] ⚠️ Fallback straight-line (Saved Pickup): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + + // Method 4: Final fallback - calculate from delivery location to current location + if (calculatedRiderKms == 0.0 && + deliveryLat != '0' && + deliveryLng != '0') { + final dLat = double.tryParse(deliveryLat) ?? 0.0; + final dLng = double.tryParse(deliveryLng) ?? 0.0; + if (dLat != 0 && dLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + dLat, + dLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedRiderKms = routeKm; + debugPrint( + '[DELIVERED] ✅ Route distance (Delivery Location): ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line + final distanceMeters = Geolocator.distanceBetween( + dLat, + dLng, + riderLat, + riderLng, + ); + calculatedRiderKms = distanceMeters / 1000.0; + debugPrint( + '[DELIVERED] ⚠️ Fallback straight-line (Delivery Location): ${distanceMeters.toStringAsFixed(2)} meters = ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + } + } + } + } catch (e) { + debugPrint('[DELIVERED] Error calculating distance: $e'); + } + } + + // CRITICAL: Ensure riderkms is never 0 or null - use minimum value if calculation failed + // Even 10 meters should be shown (0.01 km) + if (calculatedRiderKms == 0.0) { + debugPrint( + '[DELIVERED] ⚠️ WARNING: Could not calculate distance, using minimum 0.01 km (10 meters)', + ); + calculatedRiderKms = 0.01; // Minimum 10 meters + } + + debugPrint( + '[DELIVERED] Final riderkms: ${calculatedRiderKms.toStringAsFixed(4)} km', + ); + + // ---- BONUS POINTS LOGIC ---- + int bonusPoints = 0; + if (orderId.isNotEmpty) { + try { + final endKey = 'eta_endtime_$orderId'; + final endSeconds = prefs.getInt(endKey); + + if (endSeconds != null) { + final currentSeconds = + DateTime.now().millisecondsSinceEpoch ~/ 1000; + // If delivered before or at the end time, give bonus points + if (currentSeconds <= endSeconds) { + // Bonus points = riderkms rounded + bonusPoints = calculatedRiderKms.round(); + debugPrint( + '[DELIVERED] 🏆 ON TIME! Bonus Points: $bonusPoints (RiderKms: $calculatedRiderKms)', + ); + } else { + debugPrint( + '[DELIVERED] ⏳ LATE! No Bonus Points. (Deadline: $endSeconds, Now: $currentSeconds)', + ); + } + } else { + debugPrint('[DELIVERED] No ETA timer found for bonus logic.'); + } + } catch (e) { + debugPrint('[DELIVERED] Error calculating bonus points: $e'); + } + } + + final double riderChargeRate = await _getRiderChargeRate(); + + // ---- SKIP PENALTY CHECK ---- + // If the rider has exceeded skip limits in the last 3 hours, forfeit bonus points + final skipStatus = await checkSkipStatus(); + if (skipStatus['penalty'] == true) { + if (bonusPoints > 0) { + debugPrint('[DELIVERED] ⚠️ Bonus points ($bonusPoints) forfeited due to skip penalty.'); + bonusPoints = 0; + } + } + + final double riderChargesAmountRaw = + riderChargeRate > 0 && calculatedRiderKms > 0 + ? calculatedRiderKms * riderChargeRate + : 0.0; + // Ensure ridercharges is sent with 2 decimal places (Decimal(10,2) style) + final double riderChargesAmount = double.parse( + riderChargesAmountRaw.toStringAsFixed(2), + ); + + // CRITICAL: Always set deliverytime to current time right before creating payload + // This ensures deliverytime is ALWAYS passed when confirming delivery, no matter what + // Setting it here (right before payload) ensures it's the exact time of confirmation + final currentTime = DateTime.now(); + final currentDeliveryTime = _formatDateTimeFull(currentTime); + _deliveredTime = currentDeliveryTime; // Update the instance variable too + + debugPrint('[DELIVERED] Setting deliverytime to: $currentDeliveryTime'); + + // CRITICAL: Ensure riderkms is always a valid string, never null or empty + final riderKmsString = calculatedRiderKms > 0 + ? calculatedRiderKms.toStringAsFixed( + 4, + ) // Use 4 decimals to show even 10 meters (0.0100 km) + : '0.0100'; // Fallback minimum (10 meters) + + // CRITICAL: Validate coordinates before creating payload - NEVER send '0' or null + final ridersLatStr = ll['lat'] ?? '0'; + final ridersLngStr = ll['lng'] ?? '0'; + final ridersLatDouble = double.tryParse(ridersLatStr) ?? 0.0; + final ridersLngDouble = double.tryParse(ridersLngStr) ?? 0.0; + + if (ridersLatDouble == 0 || + ridersLngDouble == 0 || + ridersLatDouble.abs() > 90 || + ridersLngDouble.abs() > 180) { + debugPrint( + '[DELIVERED] ❌ ERROR: Invalid rider coordinates (lat=$ridersLatStr, lng=$ridersLngStr) - cannot proceed', + ); + deliveredShimmer.value = false; + return false; // Don't send invalid coordinates + } + + // Validate delivery coordinates + final deliveryLatDouble = double.tryParse(deliveryLat) ?? 0.0; + final deliveryLngDouble = double.tryParse(deliveryLng) ?? 0.0; + if (deliveryLatDouble == 0 || + deliveryLngDouble == 0 || + deliveryLatDouble.abs() > 90 || + deliveryLngDouble.abs() > 180) { + debugPrint( + '[DELIVERED] ❌ ERROR: Invalid delivery coordinates (lat=$deliveryLat, lng=$deliveryLng) - cannot proceed', + ); + deliveredShimmer.value = false; + return false; // Don't send invalid coordinates + } + + debugPrint( + '[DELIVERED] ✅ Validated coordinates - Rider: ($ridersLatStr, $ridersLngStr), Delivery: ($deliveryLat, $deliveryLng)', + ); + + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'deliverylocationid': deliveryLocationId, + 'orderstatus': 'delivered', + 'deliveredtime': currentDeliveryTime, + 'deliverytime': + currentDeliveryTime, // ALWAYS pass current time - required field, set right before payload + 'smsdelivery': smsDelivery, + 'riderslat': ridersLatStr, // Guaranteed non-zero and valid + 'riderslon': ridersLngStr, // Guaranteed non-zero and valid + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'pickuplat': pickupLat, + 'pickuplong': pickupLng, + 'deliverylat': deliveryLat, // Guaranteed non-zero and valid + 'deliverylong': deliveryLng, // Guaranteed non-zero and valid + 'riderkms': + riderKmsString, // ALWAYS pass riderkms - never null, shows even 10 meters (0.0100 km) + 'ridercharges': riderChargesAmount, + 'deliveryamt': deliveryAmount, + 'collectionamt': collectionAmount, + 'collectedamt': collectedAmount, + 'collectionstatus': collectionStatus, + 'ridertime': riderTimeMinutes, + 'notes': notes, + 'wasskipped': wasSkipped, + 'bonuspts': bonusPoints, // ✅ ADDED BONUS POINTS + 'dropimage': proofImage ?? '', // Include in payload + }; + + + // Update observable for UI + lastBonusPoints.value = bonusPoints; + + debugPrint( + '[DELIVERED] Payload includes deliverytime: $currentDeliveryTime', + ); + debugPrint('[DELIVERED] Payload includes riderkms: $riderKmsString km'); + debugPrint('[DELIVERED] Payload includes bonuspts: $bonusPoints'); + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + + // Save current RIDER GPS location as last delivery location for next delivery + if (ok) { + final actualLat = ll['lat'] ?? '0'; + final actualLng = ll['lng'] ?? '0'; + if (actualLat != '0' && actualLng != '0') { + await _saveLastDeliveryLocation(actualLat, actualLng); + } + + // CRITICAL: Clean up cumulative distance tracking after delivery is completed + try { + final cumulativeKm = + prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? + ''; + if (cumulativeKm.isNotEmpty) { + debugPrint( + '[DELIVERED] 📊 Final cumulative distance used: $cumulativeKm km', + ); + } + // Clean up tracking data + await prefs.remove('delivery_tracking_${deliveryId}_lastLat'); + await prefs.remove('delivery_tracking_${deliveryId}_lastLng'); + await prefs.remove('delivery_tracking_${deliveryId}_cumulativeKm'); + await prefs.remove('delivery_tracking_${deliveryId}_lastUpdateMs'); + await prefs.remove('ridertime_start_$deliveryId'); + await prefs.remove('delivery_proximity_alerted_$deliveryId'); + + final activeTracking = prefs.getStringList('active_tracking_delivery_ids') ?? []; + if (activeTracking.contains(deliveryId.toString())) { + activeTracking.remove(deliveryId.toString()); + await prefs.setStringList('active_tracking_delivery_ids', activeTracking); + } + + // ✅ Clean up ETA timer + if (orderId.isNotEmpty) { + await prefs.remove('eta_endtime_$orderId'); + } + debugPrint( + '[DELIVERED] 🧹 Cleaned up distance tracking for deliveryId: $deliveryId', + ); + } catch (e) { + debugPrint('[DELIVERED] Error cleaning up tracking: $e'); + } + } + + // Reset foreground delivery notification flag + await setNotificationSent(false); + deliveredShimmer.value = false; + + if (!ok) { + debugPrint('[UPDATE][DELIVERED][FAILED] resp=${jsonEncode(resp)}'); + } else { + // --- MQTT LOGIC --- + NearleMqttService().publishLog('delivery_completed', payload); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][DELIVERED][ERROR] $e'); + deliveredShimmer.value = false; + return false; + } + } + + // ---------------- Date/Time helpers ---------------- + String _two(int n) => n.toString().padLeft(2, '0'); + + String _formatDateTimeFull(DateTime dt) { + final y = dt.year.toString(); + final m = _two(dt.month); + final d = _two(dt.day); + final hh = _two(dt.hour); + final mm = _two(dt.minute); + final ss = _two(dt.second); + return "$y-$m-$d $hh:$mm:$ss"; + } + + String _resolveUpdateUrl() { + return ApiConstants.mainRoute == 'live' + ? ApiConstants.updateDeliveryLive + : ApiConstants.updateDeliveryDev; + } + + // ---------------- SharedPrefs helpers ---------------- + Future setNotificationSent(bool value) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('notificationSent', value); + } catch (_) {} + } + + Future getLogSecondsOrDefault([int fallback = 60]) async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt('logSeconds') ?? fallback; + } catch (_) { + return fallback; + } + } + + Future persistDeliveryLogSnapshot( + List> logs, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('deliveryLog', jsonEncode(logs)); + } catch (_) {} + } + + // ---------------- Geofencing helpers ---------------- + Future _getDeliveryRadius() async { + try { + final prefs = await SharedPreferences.getInstance(); + return prefs.getInt('deliveryradius') ?? 100; + } catch (_) { + return 100; + } + } + + // Helper method to show snackbar reliably in both debug and release builds + void _showErrorSnackbar( + String title, + String message, { + Color? bgColor, + int seconds = 4, + int retryCount = 0, + }) { + // Prevent infinite recursion + if (retryCount > 3) { + print('[GEOFENCE] Max retries reached for snackbar. Title: $title'); + return; + } + + try { + final context = Get.key.currentContext; + + // Get bottom safe area padding + final double bottomSafePadding = context != null + ? MediaQuery.of(context).padding.bottom + : 0; + + Get.snackbar( + title, + message, + backgroundColor: bgColor ?? Colors.red, + colorText: Colors.white, + duration: Duration(seconds: seconds), + snackPosition: SnackPosition.BOTTOM, + isDismissible: true, + shouldIconPulse: true, + margin: EdgeInsets.fromLTRB( + 12, + 0, + 12, + 12 + bottomSafePadding, // SafeArea bottom + ), + borderRadius: 10, + maxWidth: 400, + forwardAnimationCurve: Curves.easeOutBack, + reverseAnimationCurve: Curves.easeInBack, + ); + } catch (e) { + print( + '[GEOFENCE] Error showing snackbar (attempt ${retryCount + 1}): $e', + ); + print( + '[GEOFENCE] GetX context available: ${Get.key.currentContext != null}', + ); + + Future.delayed(const Duration(milliseconds: 400), () { + try { + Get.snackbar( + title, + message, + backgroundColor: bgColor ?? Colors.red, + colorText: Colors.white, + snackPosition: SnackPosition.BOTTOM, + ); + } catch (e2) { + print('[GEOFENCE] Retry snackbar failed: $e2'); + + if (retryCount < 2) { + Future.delayed(const Duration(milliseconds: 300), () { + _showErrorSnackbar( + title, + message, + bgColor: bgColor, + seconds: seconds, + retryCount: retryCount + 1, + ); + }); + } + } + }); + } + } + + Future _checkGeofence( + double targetLat, + double targetLng, + double currentLat, + double currentLng, + String action, + ) async { + // Validate coordinates - ensure they are valid GPS coordinates + final bool hasValidTarget = + targetLat != 0 && + targetLng != 0 && + targetLat.abs() <= 90 && + targetLng.abs() <= 180; + final bool hasValidCurrent = + currentLat != 0 && + currentLng != 0 && + currentLat.abs() <= 90 && + currentLng.abs() <= 180; + + if (!hasValidTarget || !hasValidCurrent) { + // If coordinates are missing or invalid, show error and block + if (kDebugMode) { + debugPrint( + '[GEOFENCE] Missing or invalid coordinates for $action. Target: ($targetLat, $targetLng), Current: ($currentLat, $currentLng)', + ); + } + + // Show warning snackbar using helper method + _showErrorSnackbar( + 'Location Warning', + 'Missing coordinates. Proceeding with update.', + bgColor: Colors.orange, + seconds: 3, + ); + return true; // Allow update to proceed despite missing coords + } + + try { + final radius = await _getDeliveryRadius(); + final distance = Geolocator.distanceBetween( + targetLat, + targetLng, + currentLat, + currentLng, + ); + final distanceKm = distance / 1000.0; + final radiusKm = radius / 1000.0; + final distanceMeters = distance; + + if (kDebugMode) { + debugPrint( + '[GEOFENCE] Action: $action | Target: ($targetLat, $targetLng) | Current: ($currentLat, $currentLng) | Distance: ${distanceMeters.toStringAsFixed(1)}m (${distanceKm.toStringAsFixed(3)}km) | Radius: ${radius}m (${radiusKm.toStringAsFixed(3)}km)', + ); + } + + if (distance > radius) { + // Show error snackbar with clear message using helper method + _showErrorSnackbar( + 'Location Error', + 'You are too far from the location to mark as $action.\nDistance: ${distanceMeters.toStringAsFixed(0)} m (${distanceKm.toStringAsFixed(2)} km)\nRequired: Within $radius m', + seconds: 5, + ); + return false; + } + return true; + } catch (e) { + if (kDebugMode) { + debugPrint('[GEOFENCE] Error calculating distance: $e'); + } + // On error, show warning but allow (fail-safe) + _showErrorSnackbar( + 'Location Warning', + 'Unable to verify distance. Proceeding with caution.', + bgColor: Colors.orange, + seconds: 3, + ); + return true; // Allow on error (fail-safe) + } + } + + // Get last delivery location (for calculating riderkms between deliveries) + Future> _getLastDeliveryLocation() async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = prefs.getString('lastDeliveryLat') ?? ''; + final lng = prefs.getString('lastDeliveryLng') ?? ''; + if (lat.isNotEmpty && lng.isNotEmpty) { + return {'lat': lat, 'lng': lng}; + } + } catch (_) {} + return {'lat': '', 'lng': ''}; + } + + // Save last delivery location (rider's actual GPS location, not destination) + Future _saveLastDeliveryLocation(String lat, String lng) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('lastDeliveryLat', lat); + await prefs.setString('lastDeliveryLng', lng); + debugPrint('[TRACKING] Saved last delivery location: lat=$lat, lng=$lng'); + } catch (e) { + debugPrint('[TRACKING] Error saving last delivery location: $e'); + } + } + + // Save start location for a specific delivery (when "Start Navigation" is slid) + Future _saveDeliveryStartLocation( + int deliveryId, + String lat, + String lng, + ) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('delivery_start_${deliveryId}_lat', lat); + await prefs.setString('delivery_start_${deliveryId}_lng', lng); + debugPrint( + '[TRACKING] Saved start location for delivery $deliveryId: lat=$lat, lng=$lng', + ); + } catch (e) { + debugPrint('[TRACKING] Error saving delivery start location: $e'); + } + } + + // Get start location for a specific delivery + Future> _getDeliveryStartLocation(int deliveryId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = prefs.getString('delivery_start_${deliveryId}_lat') ?? ''; + final lng = prefs.getString('delivery_start_${deliveryId}_lng') ?? ''; + if (lat.isNotEmpty && lng.isNotEmpty) { + return {'lat': lat, 'lng': lng}; + } + } catch (_) {} + return {'lat': '', 'lng': ''}; + } + + // Get pickup location (for first delivery riderkms calculation) + Future> _getPickupLocation() async { + try { + final prefs = await SharedPreferences.getInstance(); + final lat = prefs.getString('pickupLat') ?? ''; + final lng = prefs.getString('pickupLng') ?? ''; + if (lat.isNotEmpty && lng.isNotEmpty) { + return {'lat': lat, 'lng': lng}; + } + } catch (_) {} + return {'lat': '', 'lng': ''}; + } + + // Get road distance from the OSRM routing backend (actual road distance, not + // straight-line). Returns kilometres, or null on failure so callers fall back + // to the straight-line estimate. + // + // Identical origin/destination pairs are served from the routing service's + // cache, so repeated quotes for the same delivery cost one request. + Future _getRouteDistanceKm( + double originLat, + double originLng, + double destLat, + double destLng, + ) async { + final distanceKm = await RoutingService.getRouteDistanceKm( + origin: LatLng(originLat, originLng), + destination: LatLng(destLat, destLng), + ); + + if (distanceKm == null) { + debugPrint('[ROUTE] No route distance available, using fallback'); + return null; + } + + debugPrint('[ROUTE] Route distance: ${distanceKm.toStringAsFixed(4)} km'); + return distanceKm; + } + + // Save pickup location + Future _savePickupLocation(String lat, String lng) async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('pickupLat', lat); + await prefs.setString('pickupLng', lng); + debugPrint('[TRACKING] Saved pickup location: lat=$lat, lng=$lng'); + } catch (e) { + debugPrint('[TRACKING] Error saving pickup location: $e'); + } + } + + Future _getRiderChargeRate() async { + try { + final prefs = await SharedPreferences.getInstance(); + // Prefer new fuelcharge key; fall back to legacy firstmilecharge if needed + Object? raw = prefs.get('fuelcharge'); + raw ??= prefs.get('firstmilecharge'); + if (raw is double) return raw; + if (raw is int) return raw.toDouble(); + if (raw is String) { + return double.tryParse(raw) ?? 0.0; + } + } catch (_) {} + return 0.0; + } + + // Clear delivery tracking (when all deliveries are done or starting fresh) + Future clearDeliveryTracking() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('lastDeliveryLat'); + await prefs.remove('lastDeliveryLng'); + await prefs.remove('pickupLat'); + await prefs.remove('pickupLng'); + debugPrint('[TRACKING] Cleared all delivery tracking data'); + } catch (e) { + debugPrint('[TRACKING] Error clearing delivery tracking: $e'); + } + } + + // ---------------- Status Updates ---------------- + + // Accepted + Future updateAcceptedStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String notes = '', + }) async { + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final acceptedTime = _formatDateTimeFull(DateTime.now()); + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'accepted', + 'acceptedtime': acceptedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + if (!ok) { + // Log entire response for debugging + try { + debugPrint('[UPDATE][ACCEPTED][FAILED] resp=${jsonEncode(resp)}'); + } catch (_) {} + } + return ok; + } catch (e) { + debugPrint('[UPDATE][ACCEPTED][ERROR] $e'); + return false; + } + } + + // Active (start navigation) + Future updateActiveStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String notes = '', + String orderId = '', + }) async { + try { + final now = DateTime.now(); + _activeTime = _formatDateTimeFull(now); + final prefs = await SharedPreferences.getInstance(); + String startTime = prefs.getString('delivery_starttime_$deliveryId') ?? ''; + + if (startTime.isEmpty) { + startTime = _formatDateTimeFull(now); + } else { + debugPrint('[UPDATE][ACTIVE] Using preserved starttime: $startTime'); + } + + // Ensure we never send 0/0 for rider location. + Map ll = await _ensureLatLng(ridersLat, ridersLng); + if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') { + try { + final last = await Geolocator.getLastKnownPosition(); + if (last != null) { + ll = { + 'lat': last.latitude.toStringAsFixed(6), + 'lng': last.longitude.toStringAsFixed(6), + }; + } + } catch (_) {} + } + if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') { + try { + final pos = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.low, + timeLimit: const Duration(seconds: 3), + ); + ll = { + 'lat': pos.latitude.toStringAsFixed(6), + 'lng': pos.longitude.toStringAsFixed(6), + }; + } catch (_) {} + } + + // If we still don't have a valid location, bail out to avoid sending 0/0. + if ((ll['lat'] ?? '0') == '0' || (ll['lng'] ?? '0') == '0') { + debugPrint('[UPDATE][ACTIVE] No valid rider coordinates; aborting'); + return false; + } + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'active', + 'activetime': _activeTime, + 'starttime': startTime, // Add starttime when status changes to active + 'activelat': ll['lat'], + 'activelon': ll['lng'], + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateActiveDelivery(payload, url); + final ok = _isSuccess(resp); + + if (ok) { + final lat = ll['lat'] ?? '0'; + final lng = ll['lng'] ?? '0'; + if (lat != '0' && lng != '0') { + await _saveDeliveryStartLocation(deliveryId, lat, lng); + } + + // Save starttime to SharedPreferences for delivery logs (using deliveryId as key) + // This will be retrieved when creating delivery log payloads + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('delivery_starttime_$deliveryId', startTime); + if (orderId.isNotEmpty) { + await prefs.setString('current_riding_order_id', orderId); + } + await prefs.setString('current_riding_delivery_id', deliveryId.toString()); + debugPrint( + '[UPDATE][ACTIVE] Saved starttime: $startTime for deliveryId: $deliveryId', + ); + + // CRITICAL: Initialize cumulative distance tracking when delivery becomes active + // Reset any previous tracking data and set initial location + await prefs.setString('delivery_tracking_${deliveryId}_lastLat', lat); + await prefs.setString('delivery_tracking_${deliveryId}_lastLng', lng); + await prefs.setString( + 'delivery_tracking_${deliveryId}_cumulativeKm', + '0.0', + ); + await prefs.setInt( + 'delivery_tracking_${deliveryId}_lastUpdateMs', + DateTime.now().millisecondsSinceEpoch, + ); + + final activeTracking = prefs.getStringList('active_tracking_delivery_ids') ?? []; + if (!activeTracking.contains(deliveryId.toString())) { + activeTracking.add(deliveryId.toString()); + await prefs.setStringList('active_tracking_delivery_ids', activeTracking); + } + + debugPrint( + '[UPDATE][ACTIVE] 🚀 Initialized distance tracking for deliveryId: $deliveryId at ($lat, $lng)', + ); + } catch (e) { + debugPrint('[UPDATE][ACTIVE] Error saving starttime: $e'); + } + } else { + debugPrint('[UPDATE][ACTIVE][FAILED] resp=${jsonEncode(resp)}'); + } + + return ok; + } catch (e) { + debugPrint('[UPDATE][ACTIVE][ERROR] $e'); + return false; + } + } + + // Arrived + Future updateArrivedStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String pickupLat = '0', + String pickupLng = '0', + String notes = '', + }) async { + // START LOADING IMMEDIATELY for better UX + arrivedShimmer.value = true; + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final rLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final rLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final pLat = double.tryParse(pickupLat) ?? 0.0; + final pLng = double.tryParse(pickupLng) ?? 0.0; + + // Geofence Check: Arrived -> Pickup Location + final inFence = await _checkGeofence(pLat, pLng, rLat, rLng, 'Arrived'); + if (!inFence) { + arrivedShimmer.value = false; + return false; + } + + final now = DateTime.now(); + _arrivedTime = _formatDateTimeFull(now); + + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'arrived', + 'arrivaltime': _arrivedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateArrivedDelivery(payload, url); + final ok = _isSuccess(resp); + + // Stop shimmer immediately after response + arrivedShimmer.value = false; + + if (!ok) { + debugPrint('[UPDATE][ARRIVED][FAILED] resp=${jsonEncode(resp)}'); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][ARRIVED][ERROR] $e'); + arrivedShimmer.value = false; + return false; + } + } + + // Rejected (remove from queue) + Future updateRejectedStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String notes = '', + }) async { + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'rejected', + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + + if (!ok) { + debugPrint('[UPDATE][REJECTED][FAILED] resp=${jsonEncode(resp)}'); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][REJECTED][ERROR] $e'); + return false; + } + } + + // Cancelled (delivery cancelled by rider) + Future updateCancelledStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String pickupLat = '0', + String pickupLng = '0', + String deliveryLat = '0', + String deliveryLng = '0', + String notes = '', + double actualKms = 0.0, + bool wasSkipped = false, + }) async { + try { + final now = DateTime.now(); + _cancelledTime = _formatDateTimeFull(now); + + final ll = await _ensureLatLng(ridersLat, ridersLng); + + final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + final dLat = double.tryParse(deliveryLat) ?? 0.0; + final dLng = double.tryParse(deliveryLng) ?? 0.0; + + // ✅ SPECIAL CASE: Skip geofence check for "Incorrect Location" cancellation + // When user cancels due to incorrect location, we don't verify range + final bool isIncorrectLocation = notes.toLowerCase().contains( + 'incorrect location', + ); + + if (isIncorrectLocation) { + debugPrint( + '[CANCELLED] ⚠️ Skipping geofence check - Reason: Incorrect Location', + ); + } else { + // Geofence Check: Cancelled -> Delivery Location (for all other reasons) + final inFence = await _checkGeofence( + dLat, + dLng, + riderLat, + riderLng, + 'Cancelled', + ); + if (!inFence) { + debugPrint( + '[CANCELLED] ❌ Geofence check failed - Rider not in range', + ); + return false; + } + } + + // PRIORITY 1: Use cumulative distance tracked from active to cancelled (most accurate) + // This is the actual distance the rider traveled, calculated from GPS points every 30 seconds + double calculatedKms = 0.0; + try { + final prefs = await SharedPreferences.getInstance(); + final cumulativeKmStr = + prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? + ''; + if (cumulativeKmStr.isNotEmpty) { + final cumulativeKm = double.tryParse(cumulativeKmStr) ?? 0.0; + if (cumulativeKm > 0) { + calculatedKms = cumulativeKm; + debugPrint( + '[CANCELLED] ✅ Using CUMULATIVE tracked distance: ${calculatedKms.toStringAsFixed(4)} km', + ); + debugPrint( + '[CANCELLED] 📊 This is the actual distance traveled from active to cancelled', + ); + } + } + } catch (e) { + debugPrint('[CANCELLED] Error getting cumulative distance: $e'); + } + + // PRIORITY 2: Fallback to actualKms if cumulative not available + if (calculatedKms == 0.0) { + calculatedKms = actualKms; + } + + if (wasSkipped && calculatedKms == 0.0) { + try { + final lastDelivery = await _getLastDeliveryLocation(); + final lastLat = double.tryParse(lastDelivery['lat'] ?? '') ?? 0.0; + final lastLng = double.tryParse(lastDelivery['lng'] ?? '') ?? 0.0; + if (lastLat != 0 && lastLng != 0 && riderLat != 0 && riderLng != 0) { + final distanceMeters = Geolocator.distanceBetween( + lastLat, + lastLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[CANCELLED] Skipped delivery - rider travelled ${calculatedKms.toStringAsFixed(2)} km from last stop to cancellation point', + ); + } + } catch (e) { + debugPrint('[CANCELLED] Error calculating skipped distance: $e'); + } + } + + if (calculatedKms == 0.0) { + try { + // ANTI-CHEAT: Try to get the locked "Start Location" for this delivery + final startLoc = await _getDeliveryStartLocation(deliveryId); + final startLatStr = startLoc['lat'] ?? ''; + final startLngStr = startLoc['lng'] ?? ''; + + if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) { + final startLat = double.tryParse(startLatStr) ?? 0.0; + final startLng = double.tryParse(startLngStr) ?? 0.0; + if (startLat != 0 && + startLng != 0 && + riderLat != 0 && + riderLng != 0) { + // Try Google Maps route distance first (accurate road distance) + final routeKm = await _getRouteDistanceKm( + startLat, + startLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedKms = routeKm; + debugPrint( + '[CANCELLED] ✅ Route distance (Anti-Cheat): ${calculatedKms.toStringAsFixed(4)} km from Start Location to cancellation point', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + startLat, + startLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[CANCELLED] ⚠️ Fallback straight-line (Anti-Cheat): ${calculatedKms.toStringAsFixed(4)} km', + ); + } + } + } else { + // Fallback to Pickup -> Current + final pickLat = double.tryParse(pickupLat) ?? 0.0; + final pickLng = double.tryParse(pickupLng) ?? 0.0; + if (pickLat != 0 && + pickLng != 0 && + riderLat != 0 && + riderLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + pickLat, + pickLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedKms = routeKm; + debugPrint( + '[CANCELLED] ✅ Route distance: ${calculatedKms.toStringAsFixed(4)} km from pickup to cancellation point', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + pickLat, + pickLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[CANCELLED] ⚠️ Fallback straight-line: ${calculatedKms.toStringAsFixed(4)} km', + ); + } + } + } + } catch (e) { + debugPrint('[CANCELLED] Error calculating distance: $e'); + } + } + + final double riderChargeRate = await _getRiderChargeRate(); + final double riderChargesAmountRaw = + riderChargeRate > 0 && calculatedKms > 0 + ? calculatedKms * riderChargeRate + : 0.0; + final double riderChargesAmount = double.parse( + riderChargesAmountRaw.toStringAsFixed(2), + ); + if (riderChargeRate > 0) { + debugPrint( + '[CANCELLED] Applying rider charge rate $riderChargeRate => ridercharges ${riderChargesAmount.toStringAsFixed(2)}', + ); + } + + // CRITICAL: Clean up cumulative distance tracking after cancellation + try { + final prefs = await SharedPreferences.getInstance(); + final cumulativeKm = + prefs.getString('delivery_tracking_${deliveryId}_cumulativeKm') ?? + ''; + if (cumulativeKm.isNotEmpty) { + debugPrint( + '[CANCELLED] 📊 Final cumulative distance used: $cumulativeKm km', + ); + } + // Clean up tracking data + await prefs.remove('delivery_tracking_${deliveryId}_lastLat'); + await prefs.remove('delivery_tracking_${deliveryId}_lastLng'); + await prefs.remove('delivery_tracking_${deliveryId}_cumulativeKm'); + await prefs.remove('delivery_tracking_${deliveryId}_lastUpdateMs'); + await prefs.remove('delivery_proximity_alerted_$deliveryId'); + + final activeTracking = prefs.getStringList('active_tracking_delivery_ids') ?? []; + if (activeTracking.contains(deliveryId.toString())) { + activeTracking.remove(deliveryId.toString()); + await prefs.setStringList('active_tracking_delivery_ids', activeTracking); + } + + debugPrint( + '[CANCELLED] 🧹 Cleaned up distance tracking for deliveryId: $deliveryId', + ); + } catch (e) { + debugPrint('[CANCELLED] Error cleaning up tracking: $e'); + } + + // CRITICAL: Validate coordinates before creating payload - NEVER send '0' or null + final ridersLatStr = ll['lat'] ?? '0'; + final ridersLngStr = ll['lng'] ?? '0'; + final ridersLatDouble = double.tryParse(ridersLatStr) ?? 0.0; + final ridersLngDouble = double.tryParse(ridersLngStr) ?? 0.0; + + if (ridersLatDouble == 0 || + ridersLngDouble == 0 || + ridersLatDouble.abs() > 90 || + ridersLngDouble.abs() > 180) { + debugPrint( + '[CANCELLED] ❌ ERROR: Invalid rider coordinates (lat=$ridersLatStr, lng=$ridersLngStr) - cannot proceed', + ); + return false; // Don't send invalid coordinates + } + + debugPrint( + '[CANCELLED] ✅ Validated coordinates - Rider: ($ridersLatStr, $ridersLngStr)', + ); + + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'cancelled', + 'canceltime': _cancelledTime, + 'riderslat': ridersLatStr, // Guaranteed non-zero and valid + 'riderslon': ridersLngStr, // Guaranteed non-zero and valid + 'raw_latitude': ll['raw_lat'] ?? '0', + 'raw_longitude': ll['raw_lng'] ?? '0', + 'velocity_lat': ll['velocity_lat'] ?? '0', + 'velocity_lng': ll['velocity_lng'] ?? '0', + 'speed': ll['speed'] ?? '0', + 'heading': ll['heading'] ?? '0', + 'deliverylat': '', + 'deliverylong': '', + 'riderkms': calculatedKms.toStringAsFixed(2), + 'ridercharges': riderChargesAmount, + 'deliveryamt': 0.0, + 'notes': notes, + }; + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + + if (ok) { + // Update last delivery location to current location so next order starts here + final actualLat = ll['lat'] ?? '0'; + final actualLng = ll['lng'] ?? '0'; + if (actualLat != '0' && actualLng != '0') { + await _saveLastDeliveryLocation(actualLat, actualLng); + } + } + + if (!ok) { + debugPrint('[UPDATE][CANCELLED][FAILED] resp=${jsonEncode(resp)}'); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][CANCELLED][ERROR] $e'); + return false; + } + } + + // Skipped (delivery skipped by rider) + Future updateSkippedStatus({ + required int deliveryId, + required int orderHeaderId, + String ridersLat = '0', + String ridersLng = '0', + String notes = '', + }) async { + try { + final ll = await _ensureLatLng(ridersLat, ridersLng); + final skippedTime = _formatDateTimeFull(DateTime.now()); + final payload = { + 'deliveryid': deliveryId, + 'orderheaderid': orderHeaderId, + 'orderstatus': 'skipped', + 'skippedtime': skippedTime, + 'riderslat': ll['lat'], + 'riderslon': ll['lng'], + 'deliverylat': '', + 'deliverylong': '', + 'actualkms': '', + 'deliveryamt': 0.0, + 'notes': notes, + }; + + // Calculate distance for skipped order (Anti-Cheat) + double calculatedKms = 0.0; + try { + final riderLat = double.tryParse(ll['lat'] ?? '0') ?? 0.0; + final riderLng = double.tryParse(ll['lng'] ?? '0') ?? 0.0; + + if (riderLat != 0 && riderLng != 0) { + // Try Start Location first + final startLoc = await _getDeliveryStartLocation(deliveryId); + final startLatStr = startLoc['lat'] ?? ''; + final startLngStr = startLoc['lng'] ?? ''; + + if (startLatStr.isNotEmpty && startLngStr.isNotEmpty) { + final startLat = double.tryParse(startLatStr) ?? 0.0; + final startLng = double.tryParse(startLngStr) ?? 0.0; + if (startLat != 0 && startLng != 0) { + // Try Google Maps route distance first (accurate road distance) + final routeKm = await _getRouteDistanceKm( + startLat, + startLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedKms = routeKm; + debugPrint( + '[SKIPPED] ✅ Route distance (Start Location): ${calculatedKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + startLat, + startLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[SKIPPED] ⚠️ Fallback straight-line (Start Location): ${calculatedKms.toStringAsFixed(4)} km', + ); + } + } + } else { + // Fallback to Last Delivery Location + final lastDelivery = await _getLastDeliveryLocation(); + final lastLat = double.tryParse(lastDelivery['lat'] ?? '') ?? 0.0; + final lastLng = double.tryParse(lastDelivery['lng'] ?? '') ?? 0.0; + if (lastLat != 0 && lastLng != 0) { + // Try Google Maps route distance first + final routeKm = await _getRouteDistanceKm( + lastLat, + lastLng, + riderLat, + riderLng, + ); + if (routeKm != null && routeKm > 0) { + calculatedKms = routeKm; + debugPrint( + '[SKIPPED] ✅ Route distance (Last Delivery): ${calculatedKms.toStringAsFixed(4)} km', + ); + } else { + // Fallback to straight-line if API fails + final distanceMeters = Geolocator.distanceBetween( + lastLat, + lastLng, + riderLat, + riderLng, + ); + calculatedKms = distanceMeters / 1000.0; + debugPrint( + '[SKIPPED] ⚠️ Fallback straight-line (Last Delivery): ${calculatedKms.toStringAsFixed(4)} km', + ); + } + } + } + } + } catch (e) { + debugPrint('[SKIPPED] Error calculating distance: $e'); + } + + if (calculatedKms > 0) { + payload['riderkms'] = calculatedKms.toStringAsFixed(2); + debugPrint('[SKIPPED] Calculated riderkms: ${payload['riderkms']}'); + } + + final url = _resolveUpdateUrl(); + final resp = await _updateProvider.updateDelivery(payload, url); + final ok = _isSuccess(resp); + + if (ok) { + // Update last delivery location to current location so next order starts here + final actualLat = ll['lat'] ?? '0'; + final actualLng = ll['lng'] ?? '0'; + if (actualLat != '0' && actualLng != '0') { + await _saveLastDeliveryLocation(actualLat, actualLng); + } + } + + if (!ok) { + debugPrint('[UPDATE][SKIPPED][FAILED] resp=${jsonEncode(resp)}'); + } + return ok; + } catch (e) { + debugPrint('[UPDATE][SKIPPED][ERROR] $e'); + return false; + } + } + + /// Check if there are active deliveries + /// Returns true ONLY if has_live_deliveries is true (verified by API) + /// This ensures PiP is only enabled when there are actually active deliveries + Future hasActiveDeliveries() async { + try { + final prefs = await SharedPreferences.getInstance(); + final hasLive = prefs.getBool('has_live_deliveries') ?? false; + + // ✅ CRITICAL: Only return true if has_live_deliveries is true + // Don't rely on active_delivery_order_id alone - it may be stale + // The API sets has_live_deliveries based on actual order status = "active" + if (!hasLive) { + // Clear stale active_delivery_order_id if has_live_deliveries is false + final activeOrderId = prefs.getString('active_delivery_order_id'); + if (activeOrderId != null && activeOrderId.isNotEmpty) { + debugPrint( + '[DELIVERIES_CONTROLLER] Clearing stale active_delivery_order_id: $activeOrderId (no active deliveries)', + ); + await prefs.remove('active_delivery_order_id'); + } + } + + debugPrint( + '[DELIVERIES_CONTROLLER] hasActiveDeliveries: $hasLive (has_live_deliveries from API)', + ); + return hasLive; + } catch (e) { + debugPrint( + '[DELIVERIES_CONTROLLER] Error checking active deliveries: $e', + ); + return false; + } + } + + // ---------------- Skip Penalty Logic ---------------- + + /// Checks the current skip count and penalty status within the 3-hour window. + /// Resets the window if 3 hours have passed since the first skip. + Future> checkSkipStatus() async { + try { + final prefs = await SharedPreferences.getInstance(); + final now = DateTime.now(); + final startStr = prefs.getString('skip_window_start'); + int count = prefs.getInt('skip_count') ?? 0; + bool penalty = prefs.getBool('skip_penalty_active') ?? false; + + if (startStr != null) { + final start = DateTime.tryParse(startStr); + if (start != null) { + final diff = now.difference(start); + if (diff.inHours >= 3) { + debugPrint('[SKIP_LOGIC] 3-hour window expired (elapsed: ${diff.inMinutes}m). Resetting skips.'); + // Reset window + count = 0; + penalty = false; + await prefs.remove('skip_window_start'); + await prefs.remove('skip_count'); + await prefs.remove('skip_penalty_active'); + } + } else { + // Invalid date string, reset + await prefs.remove('skip_window_start'); + } + } + + return {'count': count, 'penalty': penalty}; + } catch (e) { + debugPrint('[SKIP_LOGIC] Error checking status: $e'); + return {'count': 0, 'penalty': false}; + } + } + + /// Registers a skip action. + /// Increments skip count and sets penalty if specified. + /// Starts 3-hour window if not already active. + Future registerSkip({bool applyPenalty = false}) async { + try { + final prefs = await SharedPreferences.getInstance(); + final now = DateTime.now(); + + // Ensure we are working with fresh/current state + await checkSkipStatus(); + + // Re-fetch after potential reset + int count = prefs.getInt('skip_count') ?? 0; + String? startStr = prefs.getString('skip_window_start'); + + if (startStr == null) { + // Start new window + await prefs.setString('skip_window_start', now.toIso8601String()); + debugPrint('[SKIP_LOGIC] Starting new 3-hour skip window.'); + } + + count++; + await prefs.setInt('skip_count', count); + + if (applyPenalty) { + await prefs.setBool('skip_penalty_active', true); + debugPrint('[SKIP_LOGIC] Penalty activated for this session.'); + } + + debugPrint('[SKIP_LOGIC] Skip registered. Count: $count, Penalty: $applyPenalty'); + } catch (e) { + debugPrint('[SKIP_LOGIC] Error registering skip: $e'); + } + } +} + diff --git a/lib/controllers/support_ticket.dart b/lib/controllers/support_ticket.dart index 47c69c9..3cc7e97 100644 --- a/lib/controllers/support_ticket.dart +++ b/lib/controllers/support_ticket.dart @@ -1,209 +1,209 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:math' show Random; - -import 'package:get/get.dart'; -import 'package:http/http.dart' as http; -import 'package:image_picker/image_picker.dart'; -import 'package:minio/io.dart'; -import 'package:minio/minio.dart'; -import 'package:nearle/Models/supportticket/support_ticket.dart'; - -// Minimal local stub for DigitalOcean Spaces client to avoid undefined name errors. -// Replace this with a real package or implementation for production uploads. -class dospace { - static DOSpaceClient DOSpace({ - required String region, - required String accessKey, - required String secretKey, - }) => - DOSpaceClient(region: region, accessKey: accessKey, secretKey: secretKey); - - static final ACL = _ACL(); -} - -class _ACL { - final String publicRead = 'public-read'; -} - -class DOSpaceClient { - final String region; - final String accessKey; - final String secretKey; - - DOSpaceClient({ - required this.region, - required this.accessKey, - required this.secretKey, - }); - - Future putObject({ - required String bucketName, - required String objectName, - required File file, - required String acl, - required String contentType, - }) async { - // No-op stub: implement actual upload logic here or use a proper package. - await Future.value(); - } -} - -class SupportTicketController extends GetxController { - final RxList tickets = [].obs; - final RxBool isLoading = true.obs; - final RxString errorMessage = ''.obs; - final RxBool isSubmitting = false.obs; - - @override - void onInit() { - super.onInit(); - fetchTickets(); - } - - // ---------- FETCH TICKETS ---------- - Future fetchTickets() async { - try { - isLoading(true); - errorMessage(''); - - const userId = 1242; - final url = Uri.parse( - 'https://jupiter.nearle.app/live/api/v1/partners/getridersupport/?userid=$userId'); - - final response = await http.get(url, headers: { - 'Accept': 'application/json', - }); - - if (response.statusCode != 200) { - throw Exception('Server error: ${response.statusCode}'); - } - - final Map jsonResponse = json.decode(response.body); - if (jsonResponse['status'] != true) { - throw Exception(jsonResponse['message'] ?? 'Unknown error'); - } - - final List data = jsonResponse['data']; - tickets.assignAll(data.map((e) => SupportTicketModel.fromJson(e)).toList()); - } catch (e) { - errorMessage(e.toString()); - } finally { - isLoading(false); - } - } - - // ---------- UPLOAD IMAGE TO DO SPACES ---------- -Future uploadImageToDOSpaces(File imageFile, int userId) async { - try { - final rng = Random(); - const String region = "sgp1"; - const String accessKey = "DO00NQER7N2FRYZAB2HR"; - const String secretKey = "nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0"; - const String bucketName = "nearle"; - const String folderName = "support"; - - // File name - final String fileName = 'ticket-${rng.nextInt(10000)}-$userId.jpg'; - - // Object path inside the bucket - final String objectPath = "$folderName/$fileName"; - - // CDN URL you want - final String cdnUrl = "https://images.nearle.app/$objectPath"; - - // Initialize Minio - final minio = Minio( - endPoint: "$region.digitaloceanspaces.com", - accessKey: accessKey, - secretKey: secretKey, - region: region, - useSSL: true, - ); - - print("Uploading: $objectPath"); - - // Upload to DO Spaces - await minio.fPutObject( - bucketName, - objectPath, - imageFile.path, - metadata: { - "Content-Type": "image/jpeg", - "x-amz-acl": "public-read", - }, - ); - - print("Uploaded Successfully: $cdnUrl"); - return cdnUrl; - } catch (e) { - print("Upload error: $e"); - Get.snackbar("Error", "Image upload failed."); - return null; - } -} - - - // ---------- CREATE TICKET ---------- - // ---------- CREATE TICKET ---------- -Future createTicket({ - required int userid, - required String category, - required String priority, - required String subject, - required String issue, - List? attachments, -}) async { - try { - isSubmitting(true); - String imageUrl = ""; - - // Upload first image if attached - if (attachments != null && attachments.isNotEmpty) { - final XFile xFile = attachments.first; - final File file = File(xFile.path); - final uploadedUrl = await uploadImageToDOSpaces(file, userid); - - // Only assign if a valid URL (short length) - if (uploadedUrl != null && uploadedUrl.length < 200) { - imageUrl = uploadedUrl; - } else { - print("⚠️ Skipping image URL because it’s too long or invalid."); - } - } - - // Create ticket request body - final Map payload = { - 'userid': userid, - 'category': category, - 'priority': priority, - 'subject': subject, - 'issue': issue, - 'image': imageUrl, // ✅ Always short string or empty - }; - - final response = await http.post( - Uri.parse('https://jupiter.nearle.app/live/api/v1/partners/createridersupport/'), - headers: {'Accept': 'application/json', 'Content-Type': 'application/json'}, - body: jsonEncode(payload), - ); - - if (response.statusCode != 200) { - throw Exception('Ticket creation failed: ${response.statusCode}'); - } - - final jsonResponse = jsonDecode(response.body); - if (jsonResponse['status'] != true) { - throw Exception(jsonResponse['message'] ?? 'Unknown error'); - } - - await fetchTickets(); // Refresh list after success - return true; - } catch (e) { - errorMessage(e.toString()); - return false; - } finally { - isSubmitting(false); - } -} +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' show Random; + +import 'package:get/get.dart'; +import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; +import 'package:minio/io.dart'; +import 'package:minio/minio.dart'; +import 'package:nearle/Models/supportticket/support_ticket.dart'; + +// Minimal local stub for DigitalOcean Spaces client to avoid undefined name errors. +// Replace this with a real package or implementation for production uploads. +class dospace { + static DOSpaceClient DOSpace({ + required String region, + required String accessKey, + required String secretKey, + }) => + DOSpaceClient(region: region, accessKey: accessKey, secretKey: secretKey); + + static final ACL = _ACL(); +} + +class _ACL { + final String publicRead = 'public-read'; +} + +class DOSpaceClient { + final String region; + final String accessKey; + final String secretKey; + + DOSpaceClient({ + required this.region, + required this.accessKey, + required this.secretKey, + }); + + Future putObject({ + required String bucketName, + required String objectName, + required File file, + required String acl, + required String contentType, + }) async { + // No-op stub: implement actual upload logic here or use a proper package. + await Future.value(); + } +} + +class SupportTicketController extends GetxController { + final RxList tickets = [].obs; + final RxBool isLoading = true.obs; + final RxString errorMessage = ''.obs; + final RxBool isSubmitting = false.obs; + + @override + void onInit() { + super.onInit(); + fetchTickets(); + } + + // ---------- FETCH TICKETS ---------- + Future fetchTickets() async { + try { + isLoading(true); + errorMessage(''); + + const userId = 1242; + final url = Uri.parse( + 'https://jupiter.nearle.app/live/api/v1/partners/getridersupport/?userid=$userId'); + + final response = await http.get(url, headers: { + 'Accept': 'application/json', + }); + + if (response.statusCode != 200) { + throw Exception('Server error: ${response.statusCode}'); + } + + final Map jsonResponse = json.decode(response.body); + if (jsonResponse['status'] != true) { + throw Exception(jsonResponse['message'] ?? 'Unknown error'); + } + + final List data = jsonResponse['data']; + tickets.assignAll(data.map((e) => SupportTicketModel.fromJson(e)).toList()); + } catch (e) { + errorMessage(e.toString()); + } finally { + isLoading(false); + } + } + + // ---------- UPLOAD IMAGE TO DO SPACES ---------- +Future uploadImageToDOSpaces(File imageFile, int userId) async { + try { + final rng = Random(); + const String region = "sgp1"; + const String accessKey = "DO00NQER7N2FRYZAB2HR"; + const String secretKey = "nMDewX25IBEu1FM5dakK+v28/WbW3TzBAwq913+dxP0"; + const String bucketName = "nearle"; + const String folderName = "support"; + + // File name + final String fileName = 'ticket-${rng.nextInt(10000)}-$userId.jpg'; + + // Object path inside the bucket + final String objectPath = "$folderName/$fileName"; + + // CDN URL you want + final String cdnUrl = "https://images.nearle.app/$objectPath"; + + // Initialize Minio + final minio = Minio( + endPoint: "$region.digitaloceanspaces.com", + accessKey: accessKey, + secretKey: secretKey, + region: region, + useSSL: true, + ); + + print("Uploading: $objectPath"); + + // Upload to DO Spaces + await minio.fPutObject( + bucketName, + objectPath, + imageFile.path, + metadata: { + "Content-Type": "image/jpeg", + "x-amz-acl": "public-read", + }, + ); + + print("Uploaded Successfully: $cdnUrl"); + return cdnUrl; + } catch (e) { + print("Upload error: $e"); + Get.snackbar("Error", "Image upload failed."); + return null; + } +} + + + // ---------- CREATE TICKET ---------- + // ---------- CREATE TICKET ---------- +Future createTicket({ + required int userid, + required String category, + required String priority, + required String subject, + required String issue, + List? attachments, +}) async { + try { + isSubmitting(true); + String imageUrl = ""; + + // Upload first image if attached + if (attachments != null && attachments.isNotEmpty) { + final XFile xFile = attachments.first; + final File file = File(xFile.path); + final uploadedUrl = await uploadImageToDOSpaces(file, userid); + + // Only assign if a valid URL (short length) + if (uploadedUrl != null && uploadedUrl.length < 200) { + imageUrl = uploadedUrl; + } else { + print("⚠️ Skipping image URL because it's too long or invalid."); + } + } + + // Create ticket request body + final Map payload = { + 'userid': userid, + 'category': category, + 'priority': priority, + 'subject': subject, + 'issue': issue, + 'image': imageUrl, // ✅ Always short string or empty + }; + + final response = await http.post( + Uri.parse('https://jupiter.nearle.app/live/api/v1/partners/createridersupport/'), + headers: {'Accept': 'application/json', 'Content-Type': 'application/json'}, + body: jsonEncode(payload), + ); + + if (response.statusCode != 200) { + throw Exception('Ticket creation failed: ${response.statusCode}'); + } + + final jsonResponse = jsonDecode(response.body); + if (jsonResponse['status'] != true) { + throw Exception(jsonResponse['message'] ?? 'Unknown error'); + } + + await fetchTickets(); // Refresh list after success + return true; + } catch (e) { + errorMessage(e.toString()); + return false; + } finally { + isSubmitting(false); + } +} } \ No newline at end of file diff --git a/lib/services/maps/map_config.dart b/lib/services/maps/map_config.dart new file mode 100644 index 0000000..89bc943 --- /dev/null +++ b/lib/services/maps/map_config.dart @@ -0,0 +1,44 @@ +/// Map and routing endpoints. +/// +/// Everything here is open source and keyless: +/// +/// * **Tiles** — OpenFreeMap (), OpenStreetMap data +/// built with Planetiler and served as MapLibre vector tiles. No API key, no +/// registration, no request limits, MIT licensed. MapLibre adds the required +/// attribution automatically. +/// * **Routing** — OSRM (), the Open Source Routing +/// Machine. Also keyless. +/// +/// Both are overridable at build time so production can point at self-hosted +/// instances without a code change: +/// +/// flutter build apk \ +/// --dart-define=OSRM_BASE_URL=https://osrm.internal.nearle.in \ +/// --dart-define=MAP_STYLE_URL=https://tiles.internal.nearle.in/styles/liberty +class MapConfig { + const MapConfig._(); + + /// MapLibre style document. Passed straight to the map — no key to inject, + /// so unlike a commercial provider there is nothing to patch or proxy. + /// + /// OpenFreeMap styles: `liberty` (detailed), `bright`, `positron` (muted). + static const String styleUrl = String.fromEnvironment( + 'MAP_STYLE_URL', + defaultValue: 'https://tiles.openfreemap.org/styles/liberty', + ); + + /// Base URL of an OSRM instance, without a trailing slash. + /// + /// The default is the FOSSGIS community server. It is free and needs no key, + /// but it is donation-funded infrastructure with a fair-use policy — fine for + /// development, NOT appropriate for a production delivery fleet. Point this + /// at your own OSRM before shipping; see `deploy/osrm/README.md`. + static const String osrmBaseUrl = String.fromEnvironment( + 'OSRM_BASE_URL', + defaultValue: 'https://routing.openstreetmap.de/routed-car', + ); + + /// True when routing points somewhere other than the shared community server. + static bool get usingOwnRoutingServer => + !osrmBaseUrl.contains('routing.openstreetmap.de'); +} diff --git a/lib/services/maps/map_marker_icons.dart b/lib/services/maps/map_marker_icons.dart new file mode 100644 index 0000000..468fab5 --- /dev/null +++ b/lib/services/maps/map_marker_icons.dart @@ -0,0 +1,242 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +/// A marker bitmap plus a stable identity for it. +/// +/// MapLibre registers images on the style by name, so every distinct bitmap +/// needs a key that is equal whenever the pixels are equal. +@immutable +class MarkerIcon { + const MarkerIcon(this.key, this.bytes); + + final String key; + final Uint8List bytes; + + @override + bool operator ==(Object other) => other is MarkerIcon && other.key == key; + + @override + int get hashCode => key.hashCode; +} + +/// Marker bitmaps drawn at runtime with a canvas. +/// +/// MapLibre has no equivalent of Google's `BitmapDescriptor.defaultMarker*`, +/// so the pins the app used to get for free are drawn here instead. Everything +/// is generated in-process — no new image assets, no network fetch. +class MapMarkerIcons { + const MapMarkerIcons._(); + + static final Map _cache = {}; + + /// Classic teardrop pin, anchored at its tip. + static Future pin(Color color) => + _memo('pin_${color.toARGB32()}', () => _drawPin(color)); + + /// Filled circle with a white ring — used for the rider's own position. + static Future dot(Color color) => + _memo('dot_${color.toARGB32()}', () => _drawDot(color)); + + /// Numbered step marker for the multi-stop route screen. + static Future numberedCircle( + int number, { + required Color primaryColor, + bool isSkipped = false, + bool isNext = false, + }) => _memo( + 'step_${number}_${primaryColor.toARGB32()}_${isSkipped}_$isNext', + () => _drawNumberedCircle( + number, + primaryColor: primaryColor, + isSkipped: isSkipped, + isNext: isNext, + ), + ); + + static Future _memo( + String key, + Future Function() build, + ) async { + final cached = _cache[key]; + if (cached != null) return cached; + return _cache[key] = MarkerIcon(key, await build()); + } + + static Future _toBytes( + ui.PictureRecorder recorder, + int width, + int height, + ) async { + final image = await recorder.endRecording().toImage(width, height); + final data = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + return data!.buffer.asUint8List(); + } + + static Future _drawPin(Color color) async { + const double width = 72; + const double height = 96; + const double radius = 27; + const center = Offset(width / 2, radius + 4); + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + final body = Paint() + ..color = color + ..isAntiAlias = true; + final outline = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..isAntiAlias = true; + + // Tail: a curve from the circle's lower flanks down to the anchor point. + final tail = Path() + ..moveTo(center.dx - radius + 4, center.dy + radius - 9) + ..quadraticBezierTo( + center.dx - 6, + height - 14, + center.dx, + height - 4, + ) + ..quadraticBezierTo( + center.dx + 6, + height - 14, + center.dx + radius - 4, + center.dy + radius - 9, + ) + ..close(); + + canvas.drawPath(tail, body); + canvas.drawCircle(center, radius, body); + canvas.drawCircle(center, radius, outline); + canvas.drawCircle(center, 9, Paint()..color = Colors.white); + + return _toBytes(recorder, width.toInt(), height.toInt()); + } + + static Future _drawDot(Color color) async { + const double size = 48; + const center = Offset(size / 2, size / 2); + + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + + canvas.drawCircle( + center, + 20, + Paint() + ..color = color.withValues(alpha: 0.25) + ..isAntiAlias = true, + ); + canvas.drawCircle( + center, + 12, + Paint() + ..color = Colors.white + ..isAntiAlias = true, + ); + canvas.drawCircle( + center, + 9, + Paint() + ..color = color + ..isAntiAlias = true, + ); + + return _toBytes(recorder, size.toInt(), size.toInt()); + } + + /// Preserves the exact look of the previous Google Maps step marker. + static Future _drawNumberedCircle( + int number, { + required Color primaryColor, + required bool isSkipped, + required bool isNext, + }) async { + const double size = 70; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + const center = Offset(size / 2, size / 2); + + final paint = Paint()..color = isSkipped ? Colors.orange : primaryColor; + canvas.drawCircle(center, 15, paint); + + final border = Paint() + ..color = isSkipped ? Colors.orange.shade900 : Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = isSkipped ? 4 : 3; + canvas.drawCircle(center, 15, border); + + final textPainter = TextPainter( + text: TextSpan( + text: number.toString(), + style: const TextStyle( + fontSize: 19, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + ); + textPainter.layout(); + textPainter.paint( + canvas, + Offset( + center.dx - textPainter.width / 2, + center.dy - textPainter.height / 2, + ), + ); + + if (isNext) { + final signPaint = Paint() + ..color = Colors.green + ..style = PaintingStyle.fill; + final signRect = RRect.fromRectAndRadius( + Rect.fromCenter(center: const Offset(size / 2, 8), width: 45, height: 30), + const Radius.circular(4), + ); + canvas.drawRRect(signRect, signPaint); + + final signBorder = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5; + canvas.drawRRect(signRect, signBorder); + + final nextTextPainter = TextPainter( + text: const TextSpan( + text: 'NEXT', + style: TextStyle( + fontSize: 12, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + textDirection: TextDirection.ltr, + ); + nextTextPainter.layout(); + nextTextPainter.paint( + canvas, + Offset(size / 2 - nextTextPainter.width / 2, 4), + ); + + final arrowPath = Path() + ..moveTo(size / 2, 18) + ..lineTo(size / 2 - 4, 24) + ..lineTo(size / 2 + 4, 24) + ..close(); + canvas.drawPath( + arrowPath, + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill, + ); + } + + return _toBytes(recorder, size.toInt(), size.toInt()); + } +} diff --git a/lib/services/maps/nearle_map.dart b/lib/services/maps/nearle_map.dart new file mode 100644 index 0000000..1447138 --- /dev/null +++ b/lib/services/maps/nearle_map.dart @@ -0,0 +1,374 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/material.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +import 'map_config.dart'; +import 'map_marker_icons.dart'; + +export 'package:maplibre_gl/maplibre_gl.dart' + show LatLng, LatLngBounds, CameraPosition, CameraUpdate, MyLocationTrackingMode; + +/// Where a marker bitmap sits relative to its coordinate. +enum MarkerAnchor { center, bottom } + +@immutable +class NearleMapMarker { + const NearleMapMarker({ + required this.id, + required this.position, + required this.icon, + this.anchor = MarkerAnchor.bottom, + this.size = 1.0, + this.onTap, + }); + + final String id; + final LatLng position; + final MarkerIcon icon; + final MarkerAnchor anchor; + final double size; + final VoidCallback? onTap; + + /// Identity for diffing. Deliberately excludes [onTap] — a new closure on + /// every rebuild must not force the marker to be torn down and re-added. + @override + bool operator ==(Object other) => + other is NearleMapMarker && + other.id == id && + other.position.latitude == position.latitude && + other.position.longitude == position.longitude && + other.icon == icon && + other.anchor == anchor && + other.size == size; + + @override + int get hashCode => + Object.hash(id, position.latitude, position.longitude, icon, anchor, size); +} + +@immutable +class NearleMapPolyline { + const NearleMapPolyline({ + required this.id, + required this.points, + required this.color, + this.width = 5.0, + }); + + final String id; + final List points; + final Color color; + final double width; + + @override + bool operator ==(Object other) => + other is NearleMapPolyline && + other.id == id && + other.color == color && + other.width == width && + other.points.length == points.length && + (points.isEmpty || + (other.points.first.latitude == points.first.latitude && + other.points.first.longitude == points.first.longitude && + other.points.last.latitude == points.last.latitude && + other.points.last.longitude == points.last.longitude)); + + @override + int get hashCode => Object.hash(id, color, width, points.length); +} + +/// Thin, crash-tolerant facade over [MapLibreMapController]. +class NearleMapController { + NearleMapController._(this._raw, this._isAlive); + + final MapLibreMapController _raw; + final bool Function() _isAlive; + + /// Escape hatch for anything this facade does not wrap. + MapLibreMapController get raw => _raw; + + /// Frames [bounds] with uniform [padding] in logical pixels. + /// + /// Degenerate bounds (a single point, or a perfectly horizontal/vertical + /// pair) make the native fit routine fail, so they are nudged outward first. + Future fitBounds(LatLngBounds bounds, {double padding = 80}) async { + if (!_isAlive()) return; + const double minSpan = 0.0015; // ~165 m + + var south = bounds.southwest.latitude; + var north = bounds.northeast.latitude; + var west = bounds.southwest.longitude; + var east = bounds.northeast.longitude; + + if (north - south < minSpan) { + final mid = (north + south) / 2; + south = mid - minSpan / 2; + north = mid + minSpan / 2; + } + if (east - west < minSpan) { + final mid = (east + west) / 2; + west = mid - minSpan / 2; + east = mid + minSpan / 2; + } + + await _guard( + () => _raw.animateCamera( + CameraUpdate.newLatLngBounds( + LatLngBounds( + southwest: LatLng(south, west), + northeast: LatLng(north, east), + ), + left: padding, + top: padding, + right: padding, + bottom: padding, + ), + ), + ); + } + + Future animateCamera(CameraUpdate update) => + _guard(() => _raw.animateCamera(update)); + + Future moveCamera(CameraUpdate update) => + _guard(() => _raw.moveCamera(update)); + + Future _guard(Future Function() action) async { + if (!_isAlive()) return; + try { + await action(); + } catch (e) { + debugPrint('[NEARLE_MAP] Camera command skipped: $e'); + } + } +} + +/// The app's single map surface, backed by MapLibre + OpenStreetMap vector +/// tiles. No API key is involved anywhere in this widget. +/// +/// Markers and polylines are declarative: pass new lists and the widget diffs +/// them against what is on the map, updating only what actually changed. That +/// matters on the navigation screen, where the rider marker moves every few +/// metres and a clear-and-re-add would flicker. +class NearleMap extends StatefulWidget { + const NearleMap({ + super.key, + required this.initialCameraPosition, + this.markers = const [], + this.polylines = const [], + this.myLocationEnabled = false, + this.trackingMode = MyLocationTrackingMode.none, + this.compassEnabled = false, + this.zoomGesturesEnabled = true, + this.scrollGesturesEnabled = true, + this.rotateGesturesEnabled = true, + this.tiltGesturesEnabled = true, + this.onMapReady, + }); + + final CameraPosition initialCameraPosition; + final List markers; + final List polylines; + final bool myLocationEnabled; + final MyLocationTrackingMode trackingMode; + final bool compassEnabled; + final bool zoomGesturesEnabled; + final bool scrollGesturesEnabled; + final bool rotateGesturesEnabled; + final bool tiltGesturesEnabled; + final void Function(NearleMapController controller)? onMapReady; + + @override + State createState() => _NearleMapState(); +} + +class _NearleMapState extends State { + MapLibreMapController? _controller; + NearleMapController? _facade; + + bool _styleLoaded = false; + bool _disposed = false; + + final Set _registeredIcons = {}; + final Map _symbols = {}; + final Map _markerById = {}; + final Map _lines = {}; + final Map _polylineById = {}; + + bool _syncing = false; + bool _syncQueued = false; + + @override + void dispose() { + _disposed = true; + _controller?.onSymbolTapped.remove(_onSymbolTapped); + super.dispose(); + } + + bool _isAlive() => !_disposed && mounted && _styleLoaded; + + Future _onStyleLoaded() async { + final controller = _controller; + if (controller == null || _disposed) return; + + _styleLoaded = true; + // Delivery pins overlap constantly in dense areas; without this MapLibre + // silently hides the collided ones. + try { + await controller.setSymbolIconAllowOverlap(true); + await controller.setSymbolIconIgnorePlacement(true); + } catch (e) { + debugPrint('[NEARLE_MAP] Overlap config skipped: $e'); + } + + await _sync(); + + final facade = _facade; + if (facade != null && !_disposed) widget.onMapReady?.call(facade); + } + + @override + void didUpdateWidget(covariant NearleMap oldWidget) { + super.didUpdateWidget(oldWidget); + final markersChanged = !listEquals(oldWidget.markers, widget.markers); + final linesChanged = !listEquals(oldWidget.polylines, widget.polylines); + if (markersChanged || linesChanged) _sync(); + } + + /// Reconciles the widget's markers/polylines with what is on the map. + Future _sync() async { + final controller = _controller; + if (controller == null || !_styleLoaded || _disposed) return; + + // Native annotation calls are not re-entrant; coalesce overlapping syncs. + if (_syncing) { + _syncQueued = true; + return; + } + _syncing = true; + + try { + await _syncPolylines(controller); + await _syncMarkers(controller); + } catch (e) { + debugPrint('[NEARLE_MAP] Annotation sync failed: $e'); + } finally { + _syncing = false; + if (_syncQueued && !_disposed) { + _syncQueued = false; + unawaited(_sync()); + } + } + } + + Future _syncPolylines(MapLibreMapController controller) async { + final desired = {for (final p in widget.polylines) p.id: p}; + + for (final id in _lines.keys.toList()) { + if (desired.containsKey(id)) continue; + final line = _lines.remove(id); + _polylineById.remove(id); + if (line != null) await controller.removeLine(line); + } + + for (final polyline in widget.polylines) { + if (polyline.points.length < 2) continue; + final existing = _lines[polyline.id]; + final options = LineOptions( + geometry: polyline.points, + lineColor: _hex(polyline.color), + lineWidth: polyline.width, + lineOpacity: 1.0, + lineJoin: 'round', + ); + + if (existing == null) { + _lines[polyline.id] = await controller.addLine(options); + } else if (_polylineById[polyline.id] != polyline) { + await controller.updateLine(existing, options); + } + _polylineById[polyline.id] = polyline; + } + } + + Future _syncMarkers(MapLibreMapController controller) async { + for (final marker in widget.markers) { + if (_registeredIcons.add(marker.icon.key)) { + try { + await controller.addImage(marker.icon.key, marker.icon.bytes); + } catch (e) { + _registeredIcons.remove(marker.icon.key); + debugPrint('[NEARLE_MAP] Icon registration failed: $e'); + } + } + } + + final desired = {for (final m in widget.markers) m.id: m}; + + for (final id in _symbols.keys.toList()) { + if (desired.containsKey(id)) continue; + final symbol = _symbols.remove(id); + _markerById.remove(id); + if (symbol != null) await controller.removeSymbol(symbol); + } + + for (final marker in widget.markers) { + final options = SymbolOptions( + geometry: marker.position, + iconImage: marker.icon.key, + iconSize: marker.size, + iconAnchor: marker.anchor == MarkerAnchor.bottom ? 'bottom' : 'center', + ); + + final existing = _symbols[marker.id]; + if (existing == null) { + _symbols[marker.id] = await controller.addSymbol(options, { + 'nearleId': marker.id, + }); + } else if (_markerById[marker.id] != marker) { + await controller.updateSymbol(existing, options); + } + _markerById[marker.id] = marker; + } + } + + void _onSymbolTapped(Symbol symbol) { + final id = symbol.data?['nearleId']; + if (id is! String) return; + // Read the tap handler from the current widget, not the cached marker, so + // it always closes over fresh state. + for (final marker in widget.markers) { + if (marker.id == id) { + marker.onTap?.call(); + return; + } + } + } + + static String _hex(Color color) => + '#${color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2)}'; + + @override + Widget build(BuildContext context) { + return MapLibreMap( + styleString: MapConfig.styleUrl, + initialCameraPosition: widget.initialCameraPosition, + myLocationEnabled: widget.myLocationEnabled, + myLocationTrackingMode: widget.trackingMode, + compassEnabled: widget.compassEnabled, + zoomGesturesEnabled: widget.zoomGesturesEnabled, + scrollGesturesEnabled: widget.scrollGesturesEnabled, + rotateGesturesEnabled: widget.rotateGesturesEnabled, + tiltGesturesEnabled: widget.tiltGesturesEnabled, + trackCameraPosition: true, + onMapCreated: (controller) { + _controller = controller; + _facade = NearleMapController._(controller, _isAlive); + controller.onSymbolTapped.add(_onSymbolTapped); + }, + onStyleLoadedCallback: _onStyleLoaded, + ); + } +} diff --git a/lib/services/maps/routing_service.dart b/lib/services/maps/routing_service.dart new file mode 100644 index 0000000..2da6c8f --- /dev/null +++ b/lib/services/maps/routing_service.dart @@ -0,0 +1,332 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:maplibre_gl/maplibre_gl.dart'; + +import 'map_config.dart'; + +/// A road route returned by the routing backend. +class RouteResult { + const RouteResult({ + required this.points, + required this.distanceMeters, + required this.durationSeconds, + }); + + /// Decoded geometry of the route, ready to draw as a polyline. + final List points; + + /// Total road distance in metres. + final double distanceMeters; + + /// Total travel time in seconds. + final double durationSeconds; + + double get distanceKm => distanceMeters / 1000.0; + + bool get isEmpty => points.isEmpty && distanceMeters == 0; +} + +/// Road routing via OSRM — the Open Source Routing Machine. +/// +/// Replaces the Google Directions API (and `flutter_polyline_points`). OSRM is +/// keyless and self-hostable, so there is no credential to ship and no +/// per-request billing. +/// +/// Request shape: +/// GET {base}/route/v1/driving/{lon},{lat};{lon},{lat}?overview=full&geometries=polyline +/// +/// Note OSRM takes coordinates as **lon,lat** — the reverse of most APIs. +class RoutingService { + const RoutingService._(); + + static const Duration _timeout = Duration(seconds: 10); + static const Duration _cacheTtl = Duration(minutes: 30); + static const int _maxCacheEntries = 120; + + static final Map _cache = {}; + + /// Computes a driving route. [waypoints] are visited in order between + /// [origin] and [destination] — OSRM routes through all of them in a single + /// request, so a multi-stop route costs one call, not one per leg. + /// + /// Returns null when the service is unreachable or finds no route, so callers + /// can fall back to a straight line. + static Future getRoute({ + required LatLng origin, + required LatLng destination, + List waypoints = const [], + String profile = 'driving', + }) async { + if (!_isValid(origin) || !_isValid(destination)) { + debugPrint('[ROUTING] Invalid coordinates, skipping request.'); + return null; + } + + final cacheKey = _cacheKey(origin, destination, waypoints, profile); + final cached = _cache[cacheKey]; + if (cached != null && !cached.isExpired) return cached.result; + + // OSRM wants lon,lat pairs joined by ';' in the path itself. + final coordinates = [ + origin, + ...waypoints, + destination, + ].map((p) => '${p.longitude},${p.latitude}').join(';'); + + final uri = Uri.parse( + '${MapConfig.osrmBaseUrl}/route/v1/$profile/$coordinates', + ).replace( + queryParameters: const { + 'overview': 'full', + 'geometries': 'polyline', + 'alternatives': 'false', + 'steps': 'false', + }, + ); + + try { + final response = await http.get(uri).timeout(_timeout); + + if (response.statusCode != 200) { + debugPrint('[ROUTING] HTTP ${response.statusCode}'); + return null; + } + + final result = _parse(json.decode(response.body)); + if (result == null || result.isEmpty) { + debugPrint('[ROUTING] No route in response.'); + return null; + } + + _store(cacheKey, result); + return result; + } catch (e) { + debugPrint('[ROUTING] Request failed: $e'); + return null; + } + } + + /// Convenience wrapper for callers that only need road distance. + static Future getRouteDistanceKm({ + required LatLng origin, + required LatLng destination, + }) async { + final route = await getRoute(origin: origin, destination: destination); + return route?.distanceKm; + } + + // --------------------------------------------------------------------- + // Response parsing + // --------------------------------------------------------------------- + + /// Parses an OSRM `route` response. Written tolerantly so a self-hosted + /// build, a proxy, or a drop-in alternative (Valhalla's OSRM-compatible + /// endpoint, GraphHopper's) does not require code changes: numeric fields are + /// accepted as plain numbers or as `{"value": n}` objects, and the geometry + /// is looked for under several key names. + static RouteResult? _parse(dynamic body) { + if (body is! Map) return null; + + final code = body['code']; + if (code is String && code != 'Ok') { + debugPrint('[ROUTING] Service returned code: $code'); + return null; + } + + final routes = body['routes']; + if (routes is! List || routes.isEmpty) return null; + + final route = routes.first; + if (route is! Map) return null; + + var distance = _num(route['distance']); + var duration = _num(route['duration']); + + // Fall back to summing legs when totals are absent. + if (distance == 0 || duration == 0) { + final legs = route['legs']; + if (legs is List) { + var legDistance = 0.0; + var legDuration = 0.0; + for (final leg in legs) { + if (leg is! Map) continue; + legDistance += _num(leg['distance']); + legDuration += _num(leg['duration']); + } + if (distance == 0) distance = legDistance; + if (duration == 0) duration = legDuration; + } + } + + final points = _extractGeometry(route); + if (points.isEmpty && distance == 0) return null; + + return RouteResult( + points: points, + distanceMeters: distance, + durationSeconds: duration, + ); + } + + static List _extractGeometry(Map route) { + for (final key in const ['geometry', 'overview_polyline', 'polyline']) { + final value = route[key]; + if (value is String && value.isNotEmpty) { + final decoded = decodePolyline(value); + if (decoded.isNotEmpty) return decoded; + } + // Encoded polyline nested as {"points": ""}. + if (value is Map && value['points'] is String) { + final decoded = decodePolyline(value['points'] as String); + if (decoded.isNotEmpty) return decoded; + } + // GeoJSON LineString, if the caller ever requests geometries=geojson. + if (value is Map && value['coordinates'] is List) { + final coords = value['coordinates'] as List; + final points = []; + for (final c in coords) { + if (c is List && c.length >= 2) { + points.add(LatLng(_num(c[1]), _num(c[0]))); + } + } + if (points.isNotEmpty) return points; + } + } + + // Last resort: stitch the per-step geometries together. + final points = []; + final legs = route['legs']; + if (legs is List) { + for (final leg in legs) { + if (leg is! Map) continue; + final steps = leg['steps']; + if (steps is! List) continue; + for (final step in steps) { + if (step is! Map) continue; + final geometry = step['geometry'] ?? step['polyline']; + final encoded = geometry is Map ? geometry['points'] : geometry; + if (encoded is String && encoded.isNotEmpty) { + points.addAll(decodePolyline(encoded)); + } + } + } + } + return points; + } + + static double _num(dynamic value) { + if (value == null) return 0.0; + if (value is num) return value.toDouble(); + if (value is Map) return _num(value['value']); + return double.tryParse(value.toString()) ?? 0.0; + } + + // --------------------------------------------------------------------- + // Polyline codec (Google's Encoded Polyline Algorithm) + // + // OSRM's default `geometries=polyline` uses precision 5. Its `polyline6` + // option uses precision 6 — pass `precision: 6` if you switch to it. + // --------------------------------------------------------------------- + + static List decodePolyline(String encoded, {int precision = 5}) { + final points = []; + final factor = _pow10(precision); + var index = 0; + var lat = 0; + var lng = 0; + + while (index < encoded.length) { + final dLat = _decodeValue(encoded, index); + if (dLat == null) break; + index = dLat.nextIndex; + lat += dLat.value; + + final dLng = _decodeValue(encoded, index); + if (dLng == null) break; + index = dLng.nextIndex; + lng += dLng.value; + + points.add(LatLng(lat / factor, lng / factor)); + } + return points; + } + + static _Delta? _decodeValue(String encoded, int index) { + var shift = 0; + var result = 0; + int byte; + do { + if (index >= encoded.length) return null; + byte = encoded.codeUnitAt(index++) - 63; + result |= (byte & 0x1f) << shift; + shift += 5; + } while (byte >= 0x20); + final value = (result & 1) != 0 ? ~(result >> 1) : (result >> 1); + return _Delta(value, index); + } + + static double _pow10(int exponent) { + var value = 1.0; + for (var i = 0; i < exponent; i++) { + value *= 10; + } + return value; + } + + // --------------------------------------------------------------------- + // Cache + // --------------------------------------------------------------------- + + static bool _isValid(LatLng p) => + p.latitude != 0 && + p.longitude != 0 && + p.latitude.abs() <= 90 && + p.longitude.abs() <= 180; + + static String _cacheKey( + LatLng origin, + LatLng destination, + List waypoints, + String profile, + ) { + // ~11 m resolution: identical pickup/drop pairs reuse one request. + String round(LatLng p) => + '${p.latitude.toStringAsFixed(4)},${p.longitude.toStringAsFixed(4)}'; + return [ + profile, + round(origin), + round(destination), + ...waypoints.map(round), + ].join('>'); + } + + static void _store(String key, RouteResult result) { + if (_cache.length >= _maxCacheEntries) { + final oldest = _cache.entries.reduce( + (a, b) => a.value.storedAt.isBefore(b.value.storedAt) ? a : b, + ); + _cache.remove(oldest.key); + } + _cache[key] = _CacheEntry(result, DateTime.now()); + } + + @visibleForTesting + static void clearCache() => _cache.clear(); +} + +class _Delta { + const _Delta(this.value, this.nextIndex); + final int value; + final int nextIndex; +} + +class _CacheEntry { + const _CacheEntry(this.result, this.storedAt); + final RouteResult result; + final DateTime storedAt; + + bool get isExpired => + DateTime.now().difference(storedAt) > RoutingService._cacheTtl; +} diff --git a/lib/views/Dashboard/deliveries/deliveries.dart b/lib/views/Dashboard/deliveries/deliveries.dart index 3d6c18e..e3a3d94 100644 --- a/lib/views/Dashboard/deliveries/deliveries.dart +++ b/lib/views/Dashboard/deliveries/deliveries.dart @@ -5,12 +5,13 @@ import 'dart:async'; import 'dart:convert'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; -import 'package:flutter_polyline_points/flutter_polyline_points.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:geolocator/geolocator.dart'; import 'package:get/get.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:lottie/lottie.dart' hide Marker; +import 'package:nearle/services/maps/map_marker_icons.dart'; +import 'package:nearle/services/maps/nearle_map.dart'; +import 'package:nearle/services/maps/routing_service.dart'; import 'package:nearle/views/helpers/constants/Colorconstants.dart'; import 'package:nearle/views/helpers/constants/Font_constant.dart'; import 'package:slider_button_lite/feature/presentation/slider_button/slider.dart'; diff --git a/lib/views/Dashboard/deliveries/map.dart b/lib/views/Dashboard/deliveries/map.dart index 9672097..f63d9da 100644 --- a/lib/views/Dashboard/deliveries/map.dart +++ b/lib/views/Dashboard/deliveries/map.dart @@ -16,24 +16,18 @@ class _DeliveryMapScreen extends StatefulWidget { } class _DeliveryMapScreenState extends State<_DeliveryMapScreen> { - GoogleMapController? _mapController; + NearleMapController? _mapController; late final LatLng _pickupLocation; late final LatLng _dropLocation; - final Set _markers = {}; - final Set _polylines = {}; - late final PolylinePoints _polylinePoints; - bool _isLoadingRoute = true; + List _markers = const []; + List _polylines = const []; bool _isNavigating = false; // Prevent multiple clicks - static const String _googleApiKey = 'AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q'; - @override void initState() { super.initState(); - _polylinePoints = PolylinePoints(apiKey: _googleApiKey); _resolveLocationsFromDelivery(); _setMarkers(); - _isLoadingRoute = false; _createRealRoute(); } @@ -73,113 +67,70 @@ class _DeliveryMapScreenState extends State<_DeliveryMapScreen> { _dropLocation = drop; } - void _setMarkers() { - _markers.addAll([ - Marker( - markerId: const MarkerId('pickup'), - position: _pickupLocation, - infoWindow: const InfoWindow(title: 'Pickup Location'), - icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed), - ), - Marker( - markerId: const MarkerId('drop'), - position: _dropLocation, - infoWindow: const InfoWindow(title: 'Drop Location'), - icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen), - ), - ]); + Future _setMarkers() async { + final pickupIcon = await MapMarkerIcons.pin(Colors.red); + final dropIcon = await MapMarkerIcons.pin(Colors.green); + if (!mounted) return; + setState(() { + _markers = [ + NearleMapMarker( + id: 'pickup', + position: _pickupLocation, + icon: pickupIcon, + ), + NearleMapMarker(id: 'drop', position: _dropLocation, icon: dropIcon), + ]; + }); } Future _createRealRoute() async { - try { - final request = PolylineRequest( - origin: PointLatLng( - _pickupLocation.latitude, - _pickupLocation.longitude, - ), - destination: PointLatLng( - _dropLocation.latitude, - _dropLocation.longitude, - ), - mode: TravelMode.driving, - ); + final route = await RoutingService.getRoute( + origin: _pickupLocation, + destination: _dropLocation, + ); - final result = await _polylinePoints.getRouteBetweenCoordinates( - request: request, - ); - - if (result.points.isNotEmpty) { - final routePoints = result.points - .map((e) => LatLng(e.latitude, e.longitude)) - .toList(); - - if (mounted) { - setState(() { - _polylines.add( - Polyline( - polylineId: const PolylineId('real_route'), - color: ColorConstants.primaryColor, - width: 6, - points: routePoints, - ), - ); - _isLoadingRoute = false; - }); - - _fitMapToRoute(); - } - } else { - debugPrint('[MAP_PREVIEW] No route found'); - if (mounted) { - setState(() => _isLoadingRoute = false); - } - } - } catch (e) { - debugPrint('[MAP_PREVIEW] Error creating route: $e'); - if (mounted) { - setState(() => _isLoadingRoute = false); - } - } - } -Future _fitMapToRoute() async { - if (!mounted) return; - if (_mapController == null) return; - - // Check if controller is still alive (important!) - try { - await _mapController!.getVisibleRegion(); - } catch (e) { - debugPrint("❌ Map controller is dead. Skip animateCamera."); - return; - } - - final bounds = LatLngBounds( - southwest: LatLng( - math.min(_pickupLocation.latitude, _dropLocation.latitude), - math.min(_pickupLocation.longitude, _dropLocation.longitude), - ), - northeast: LatLng( - math.max(_pickupLocation.latitude, _dropLocation.latitude), - math.max(_pickupLocation.longitude, _dropLocation.longitude), - ), - ); - - // Try animate safely - for (int i = 0; i < 10; i++) { if (!mounted) return; - try { - await _mapController!.animateCamera( - CameraUpdate.newLatLngBounds(bounds, 80), - ); - return; - } catch (e) { - await Future.delayed(const Duration(milliseconds: 150)); - } + // Fall back to a straight line so the screen still shows the connection + // when routing is unavailable. + final points = (route != null && route.points.isNotEmpty) + ? route.points + : [_pickupLocation, _dropLocation]; + + if (route == null) debugPrint('[MAP_PREVIEW] No route found'); + + setState(() { + _polylines = [ + NearleMapPolyline( + id: 'real_route', + points: points, + color: ColorConstants.primaryColor, + width: 6, + ), + ]; + }); + + _fitMapToRoute(); } - debugPrint("❌ animateCamera failed after retries (map probably disposed)"); -} + Future _fitMapToRoute() async { + final controller = _mapController; + if (!mounted || controller == null) return; + + await controller.fitBounds( + LatLngBounds( + southwest: LatLng( + math.min(_pickupLocation.latitude, _dropLocation.latitude), + math.min(_pickupLocation.longitude, _dropLocation.longitude), + ), + northeast: LatLng( + math.max(_pickupLocation.latitude, _dropLocation.latitude), + math.max(_pickupLocation.longitude, _dropLocation.longitude), + ), + ), + padding: 80, + ); + } @@ -188,27 +139,20 @@ Future _fitMapToRoute() async { return Scaffold( body: Stack( children: [ - SizedBox.expand( - child: GoogleMap( - initialCameraPosition: CameraPosition( - target: _pickupLocation, - zoom: 14.5, - ), - onMapCreated: (controller) { - _mapController = controller; - - Future.delayed(const Duration(milliseconds: 500), () { - if (!_isLoadingRoute && _polylines.isNotEmpty) { - _fitMapToRoute(); - } - }); - }, - markers: _markers, - polylines: _polylines, - zoomControlsEnabled: false, - myLocationButtonEnabled: false, - ), -), + SizedBox.expand( + child: NearleMap( + initialCameraPosition: CameraPosition( + target: _pickupLocation, + zoom: 14.5, + ), + markers: _markers, + polylines: _polylines, + onMapReady: (controller) { + _mapController = controller; + _fitMapToRoute(); + }, + ), + ), Positioned( top: 50, left: 16, diff --git a/lib/views/Dashboard/deliveries/multi_map.dart b/lib/views/Dashboard/deliveries/multi_map.dart index 456471d..ce5ee5b 100644 --- a/lib/views/Dashboard/deliveries/multi_map.dart +++ b/lib/views/Dashboard/deliveries/multi_map.dart @@ -24,15 +24,11 @@ class MultiCustomerMapScreen extends StatefulWidget { } class _MultiCustomerMapScreenState extends State { - GoogleMapController? mapController; - Set markers = {}; - Set polylines = {}; + NearleMapController? mapController; + List markers = const []; + List polylines = const []; LatLng? currentLocation; bool _isLoading = true; - bool _mapReady = false; - final PolylinePoints _polylinePoints = PolylinePoints( - apiKey: 'AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q', - ); final Map> _deliveryMap = {}; @override @@ -199,7 +195,7 @@ class _MultiCustomerMapScreenState extends State { } Future _createDeliveryMarkers() async { - Set tempMarkers = {}; + final List tempMarkers = []; List<_StepPoint> stepPoints = []; _deliveryMap.clear(); @@ -231,8 +227,6 @@ class _MultiCustomerMapScreenState extends State { ); if (lat == 0 || lon == 0) continue; - final customerName = (delivery['deliverycustomer'] ?? 'Customer ${i + 1}') - .toString(); final orderId = (delivery['orderid'] ?? '').toString(); final status = (delivery['orderstatus']?.toString().toLowerCase() ?? '') .trim(); @@ -242,23 +236,19 @@ class _MultiCustomerMapScreenState extends State { // Store delivery data for dialog _deliveryMap['delivery_$orderId'] = delivery; - final icon = await _createCircularMarkerBitmap( + final icon = await MapMarkerIcons.numberedCircle( displayStep, + primaryColor: ColorConstants.primaryColor, isSkipped: isSkipped, isNext: isNext, ); tempMarkers.add( - Marker( - markerId: MarkerId('delivery_$orderId'), + NearleMapMarker( + id: 'delivery_$orderId', position: LatLng(lat, lon), icon: icon, - infoWindow: InfoWindow( - title: isSkipped - ? 'Step $displayStep: $customerName (SKIPPED)' - : 'Step $displayStep: $customerName', - snippet: 'Order #$orderId', - ), + anchor: MarkerAnchor.center, onTap: () { _showCustomerDetailsSheet(delivery, displayStep); }, @@ -270,13 +260,11 @@ class _MultiCustomerMapScreenState extends State { if (currentLocation != null) { tempMarkers.add( - Marker( - markerId: const MarkerId('current_location'), + NearleMapMarker( + id: 'current_location', position: currentLocation!, - icon: BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - ), - infoWindow: const InfoWindow(title: 'You are here'), + icon: await MapMarkerIcons.dot(Colors.blueAccent), + anchor: MarkerAnchor.center, ), ); stepPoints.insert(0, _StepPoint(0, currentLocation!)); @@ -284,67 +272,7 @@ class _MultiCustomerMapScreenState extends State { stepPoints.sort((a, b) => a.step.compareTo(b.step)); - Set newPolylines = {}; - int segmentIndex = 0; - - for (int i = 0; i < stepPoints.length - 1; i++) { - final start = stepPoints[i].position; - final end = stepPoints[i + 1].position; - - try { - // ignore: deprecated_member_use - final request = PolylineRequest( - origin: PointLatLng(start.latitude, start.longitude), - destination: PointLatLng(end.latitude, end.longitude), - mode: TravelMode.driving, - ); - - final result = await _polylinePoints.getRouteBetweenCoordinates( - request: request, - ); - - if (result.points.isNotEmpty) { - final routePoints = result.points - .map((p) => LatLng(p.latitude, p.longitude)) - .toList(); - - newPolylines.add( - Polyline( - polylineId: PolylineId('segment_$segmentIndex'), - points: routePoints, - width: 6, - color: Colors.blue, - startCap: Cap.roundCap, - endCap: Cap.roundCap, - jointType: JointType.round, - geodesic: true, - ), - ); - segmentIndex++; - } else { - newPolylines.add( - Polyline( - polylineId: PolylineId('segment_fallback_$segmentIndex'), - points: [start, end], - width: 4, - color: Colors.blue.shade200, - ), - ); - segmentIndex++; - } - } catch (e) { - debugPrint('[CUSTOMER_MAP] Directions error for segment $i: $e'); - newPolylines.add( - Polyline( - polylineId: PolylineId('segment_error_$segmentIndex'), - points: [start, end], - width: 4, - color: Colors.blue.shade200, - ), - ); - segmentIndex++; - } - } + final newPolylines = await _buildRoutePolylines(stepPoints); if (mounted) { setState(() { @@ -357,8 +285,50 @@ class _MultiCustomerMapScreenState extends State { _fitBoundsToMarkersAndPolylines(); } + /// Builds the route through every stop in one Directions call. + /// + /// This used to issue one request per consecutive pair of stops, so opening + /// this screen with N stops cost N billable requests. OSRM takes every stop + /// as a coordinate in one request, so the whole route is a single call. + Future> _buildRoutePolylines( + List<_StepPoint> stepPoints, + ) async { + if (stepPoints.length < 2) return const []; + + final route = await RoutingService.getRoute( + origin: stepPoints.first.position, + destination: stepPoints.last.position, + waypoints: stepPoints + .sublist(1, stepPoints.length - 1) + .map((s) => s.position) + .toList(), + ); + + if (route != null && route.points.isNotEmpty) { + return [ + NearleMapPolyline( + id: 'route', + points: route.points, + width: 6, + color: Colors.blue, + ), + ]; + } + + debugPrint('[CUSTOMER_MAP] Routing unavailable, drawing direct segments'); + return [ + NearleMapPolyline( + id: 'route_fallback', + points: stepPoints.map((s) => s.position).toList(), + width: 4, + color: Colors.blue.shade200, + ), + ]; + } + Future _fitBoundsToMarkersAndPolylines() async { - if (mapController == null) return; + final controller = mapController; + if (controller == null) return; double minLat = double.infinity; double maxLat = -double.infinity; @@ -367,8 +337,7 @@ class _MultiCustomerMapScreenState extends State { bool hasPoint = false; - for (final m in markers) { - final pos = m.position; + void include(LatLng pos) { minLat = math.min(minLat, pos.latitude); maxLat = math.max(maxLat, pos.latitude); minLng = math.min(minLng, pos.longitude); @@ -376,137 +345,24 @@ class _MultiCustomerMapScreenState extends State { hasPoint = true; } + for (final m in markers) { + include(m.position); + } for (final poly in polylines) { for (final pos in poly.points) { - minLat = math.min(minLat, pos.latitude); - maxLat = math.max(maxLat, pos.latitude); - minLng = math.min(minLng, pos.longitude); - maxLng = math.max(maxLng, pos.longitude); - hasPoint = true; + include(pos); } } if (!hasPoint) return; - final bounds = LatLngBounds( - southwest: LatLng(minLat, minLng), - northeast: LatLng(maxLat, maxLng), - ); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - try { - await mapController!.animateCamera( - CameraUpdate.newLatLngBounds(bounds, 80), - ); - } catch (e) { - Future.delayed(const Duration(milliseconds: 300), () async { - try { - await mapController!.animateCamera( - CameraUpdate.newLatLngBounds(bounds, 80), - ); - } catch (e) { - debugPrint('[CUSTOMER_MAP] Retry failed: $e'); - } - }); - } - }); - } - - Future _createCircularMarkerBitmap( - int number, { - bool isSkipped = false, - bool isNext = false, - }) async { - const double size = 70; - final pictureRecorder = ui.PictureRecorder(); - final canvas = Canvas(pictureRecorder); - final center = Offset(size / 2, size / 2); - - final paint = Paint() - ..color = isSkipped ? Colors.orange : ColorConstants.primaryColor; - canvas.drawCircle(center, 15, paint); - - final border = Paint() - ..color = isSkipped ? Colors.orange.shade900 : Colors.white - ..style = PaintingStyle.stroke - ..strokeWidth = isSkipped ? 4 : 3; - canvas.drawCircle(center, 15, border); - - final textPainter = TextPainter( - text: TextSpan( - text: number.toString(), - style: const TextStyle( - fontSize: 19, - color: Colors.white, - fontWeight: FontWeight.bold, - ), + await controller.fitBounds( + LatLngBounds( + southwest: LatLng(minLat, minLng), + northeast: LatLng(maxLat, maxLng), ), - textDirection: TextDirection.ltr, + padding: 80, ); - textPainter.layout(); - textPainter.paint( - canvas, - Offset( - center.dx - textPainter.width / 2, - center.dy - textPainter.height / 2, - ), - ); - - // Draw "NEXT" indicator (road sign with down arrow) on top - if (isNext) { - // Draw road sign background (rectangle) - final signPaint = Paint() - ..color = Colors.green - ..style = PaintingStyle.fill; - final signRect = RRect.fromRectAndRadius( - Rect.fromCenter(center: Offset(size / 2, 8), width: 45, height: 30), - const Radius.circular(4), - ); - canvas.drawRRect(signRect, signPaint); - - // Draw border - final signBorder = Paint() - ..color = Colors.white - ..style = PaintingStyle.stroke - ..strokeWidth = 1.5; - canvas.drawRRect(signRect, signBorder); - - // Draw "NEXT" text - final nextTextPainter = TextPainter( - text: const TextSpan( - text: 'NEXT', - style: TextStyle( - fontSize: 12, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - textDirection: TextDirection.ltr, - ); - nextTextPainter.layout(); - nextTextPainter.paint( - canvas, - Offset(size / 2 - nextTextPainter.width / 2, 4), - ); - - // Draw down arrow - final arrowPath = Path(); - arrowPath.moveTo(size / 2, 18); - arrowPath.lineTo(size / 2 - 4, 24); - arrowPath.lineTo(size / 2 + 4, 24); - arrowPath.close(); - final arrowPaint = Paint() - ..color = Colors.white - ..style = PaintingStyle.fill; - canvas.drawPath(arrowPath, arrowPaint); - } - - final img = await pictureRecorder.endRecording().toImage( - size.toInt(), - size.toInt(), - ); - final data = await img.toByteData(format: ui.ImageByteFormat.png); - return BitmapDescriptor.bytes(data!.buffer.asUint8List()); } void _showCustomerDetailsSheet( @@ -872,21 +728,14 @@ class _MultiCustomerMapScreenState extends State { ), body: _isLoading ? const Center(child: CircularProgressIndicator()) - : GoogleMap( + : NearleMap( initialCameraPosition: initialCameraPosition, markers: markers, polylines: polylines, myLocationEnabled: true, - myLocationButtonEnabled: true, - onMapCreated: (controller) { + onMapReady: (controller) { mapController = controller; - _mapReady = true; - - WidgetsBinding.instance.addPostFrameCallback((_) { - Future.delayed(const Duration(milliseconds: 150), () { - if (_mapReady) _fitBoundsToMarkersAndPolylines(); - }); - }); + _fitBoundsToMarkersAndPolylines(); }, ), ); diff --git a/lib/views/Dashboard/deliveries/nav.dart b/lib/views/Dashboard/deliveries/nav.dart index f998837..7fb5fdb 100644 --- a/lib/views/Dashboard/deliveries/nav.dart +++ b/lib/views/Dashboard/deliveries/nav.dart @@ -19,12 +19,15 @@ class _RiderNavigationScreen extends StatefulWidget { class _RiderNavigationScreenState extends State<_RiderNavigationScreen> with WidgetsBindingObserver { - GoogleMapController? mapController; + NearleMapController? mapController; bool _mapReady = false; bool _isDisposed = false; Position? _currentPosition; StreamSubscription? _positionStream; - final Set _markers = {}; + List _markers = const []; + MarkerIcon? _riderIcon; + MarkerIcon? _dropIcon; + LatLng? _lastRiderPosition; late final LatLng dropLocation; bool _isInitializing = true; bool _hasOpenedGoogleMaps = false; @@ -46,6 +49,7 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen> super.initState(); WidgetsBinding.instance.addObserver(this); _resolveDropFromDelivery(); + _loadMarkerIcons(); _initializeMap(); _initEtaForNav(); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -214,8 +218,8 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen> if (lastPos != null && mounted) { setState(() { _currentPosition = lastPos; + _addMarkers(LatLng(lastPos.latitude, lastPos.longitude)); }); - _addMarkers(LatLng(lastPos.latitude, lastPos.longitude)); _startLiveTracking(); } }); @@ -338,8 +342,8 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen> if (mounted) { setState(() { _currentPosition = position; + _addMarkers(LatLng(position.latitude, position.longitude)); }); - _addMarkers(LatLng(position.latitude, position.longitude)); final distance = Geolocator.distanceBetween( position.latitude, @@ -362,27 +366,36 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen> }); } + /// Marker bitmaps are drawn on a canvas, so they arrive asynchronously. + /// Once they land, rebuild whatever markers are already pending. + Future _loadMarkerIcons() async { + final rider = await MapMarkerIcons.dot(Colors.blueAccent); + final drop = await MapMarkerIcons.pin(Colors.red); + if (!mounted || _isDisposed) return; + setState(() { + _riderIcon = rider; + _dropIcon = drop; + _addMarkers(_lastRiderPosition ?? dropLocation); + }); + } + + /// Mutates [_markers] only — callers own the [setState] so this can be + /// invoked from inside an existing one without nesting. void _addMarkers(LatLng riderPos) { - _markers - ..clear() - ..add( - Marker( - markerId: const MarkerId('rider'), - position: riderPos, - icon: BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - ), - infoWindow: const InfoWindow(title: 'You (Rider)'), - ), - ) - ..add( - Marker( - markerId: const MarkerId('drop'), - position: dropLocation, - icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed), - infoWindow: const InfoWindow(title: 'Drop Location'), - ), - ); + _lastRiderPosition = riderPos; + final riderIcon = _riderIcon; + final dropIcon = _dropIcon; + if (riderIcon == null || dropIcon == null) return; + + _markers = [ + NearleMapMarker( + id: 'rider', + position: riderPos, + icon: riderIcon, + anchor: MarkerAnchor.center, + ), + NearleMapMarker(id: 'drop', position: dropLocation, icon: dropIcon), + ]; } Future _attemptAutoOpenNavigation({bool fromTimer = false}) async { @@ -511,9 +524,8 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen> return Scaffold( body: Stack( children: [ - GoogleMap( - mapType: MapType.normal, - onMapCreated: (controller) { + NearleMap( + onMapReady: (controller) { mapController = controller; _mapReady = true; }, @@ -527,10 +539,7 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen> ), markers: _markers, myLocationEnabled: true, - myLocationButtonEnabled: false, - trafficEnabled: true, compassEnabled: true, - buildingsEnabled: true, tiltGesturesEnabled: true, ), if (!_hasOpenedGoogleMaps) @@ -994,11 +1003,7 @@ class _RiderNavigationScreenState extends State<_RiderNavigationScreen> } void _safeAnimateCamera(CameraUpdate update) { - if (!_mapReady || _isDisposed || mapController == null) return; - try { - mapController?.animateCamera(update); - } catch (e) { - debugPrint('[NAVIGATION] animateCamera skipped: $e'); - } + if (!_mapReady || _isDisposed) return; + mapController?.animateCamera(update); } } diff --git a/lib/views/Dashboard/home/homepage.dart b/lib/views/Dashboard/home/homepage.dart index 9cac2d8..09f5ec9 100644 --- a/lib/views/Dashboard/home/homepage.dart +++ b/lib/views/Dashboard/home/homepage.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'dart:convert'; -import 'package:flutter_polyline_points/flutter_polyline_points.dart'; import 'dart:async'; import 'package:image_picker/image_picker.dart'; import 'dart:io'; @@ -22,8 +21,10 @@ import 'package:nearle/controllers/delivery.dart'; import 'package:nearle/controllers/deliveries_controller.dart'; import 'package:geolocator/geolocator.dart'; import 'package:slide_to_submit_button/slide_to_submit_button.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:geocoding/geocoding.dart'; +import 'package:nearle/services/maps/map_marker_icons.dart'; +import 'package:nearle/services/maps/nearle_map.dart'; +import 'package:nearle/services/maps/routing_service.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:floating/floating.dart'; import 'dart:math' as math; @@ -2624,7 +2625,6 @@ void _showmapDetailsSheet( double pickupLat = parseD(pickupLatStr); double pickupLng = parseD(pickupLngStr); bool hasPickup = pickupLat != 0 && pickupLng != 0; - final Completer mapController = Completer(); double riderLat = 0.0; double riderLng = 0.0; @@ -2661,36 +2661,46 @@ void _showmapDetailsSheet( final bool hasAny = hasRiderLocation && hasPickup; - final List polylineCoords = []; - final Set polylines = {}; - - const String googleAPIKey = "AIzaSyBhkGfnq27sN0wV5y_S-M2KojpFTk_by-Q"; + final List polylines = []; if (hasRiderLocation && hasPickup) { - final PolylinePoints polylinePoints = PolylinePoints(apiKey: googleAPIKey); - final PolylineResult result = await polylinePoints - .getRouteBetweenCoordinates( - request: PolylineRequest( - origin: PointLatLng(riderLat, riderLng), - destination: PointLatLng(pickupLat, pickupLng), - mode: TravelMode.driving, - ), - ); - if (result.points.isNotEmpty) { - for (var point in result.points) { - polylineCoords.add(LatLng(point.latitude, point.longitude)); - } + final route = await RoutingService.getRoute( + origin: LatLng(riderLat, riderLng), + destination: LatLng(pickupLat, pickupLng), + ); + if (route != null && route.points.isNotEmpty) { polylines.add( - Polyline( - polylineId: const PolylineId("route"), + NearleMapPolyline( + id: 'route', + points: route.points, color: Colors.blueAccent, width: 5, - points: polylineCoords, ), ); } } + final List mapMarkers = []; + if (hasRiderLocation) { + mapMarkers.add( + NearleMapMarker( + id: 'rider', + position: LatLng(riderLat, riderLng), + icon: await MapMarkerIcons.dot(Colors.blueAccent), + anchor: MarkerAnchor.center, + ), + ); + } + if (hasPickup) { + mapMarkers.add( + NearleMapMarker( + id: 'pickup', + position: LatLng(pickupLat, pickupLng), + icon: await MapMarkerIcons.pin(Colors.red), + ), + ); + } + // ⭐ STEP 3 — Close loader Navigator.pop(context); @@ -2848,7 +2858,7 @@ void _showmapDetailsSheet( decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade200), ), - child: GoogleMap( + child: NearleMap( initialCameraPosition: CameraPosition( target: hasRiderLocation && hasPickup ? LatLng( @@ -2862,52 +2872,22 @@ void _showmapDetailsSheet( : const LatLng(0, 0), zoom: hasRiderLocation && hasPickup ? 13 : 15, ), - markers: { - if (hasRiderLocation) - Marker( - markerId: const MarkerId("rider"), - position: LatLng(riderLat, riderLng), - infoWindow: const InfoWindow( - title: "Your Location", - ), - icon: BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueAzure, - ), - ), - if (hasPickup) - Marker( - markerId: const MarkerId("pickup"), - position: LatLng(pickupLat, pickupLng), - infoWindow: const InfoWindow( - title: "Pickup Location", - ), - icon: BitmapDescriptor.defaultMarkerWithHue( - BitmapDescriptor.hueRed, - ), - ), - }, + markers: mapMarkers, polylines: polylines, - zoomGesturesEnabled: true, - scrollGesturesEnabled: true, - tiltGesturesEnabled: true, - rotateGesturesEnabled: true, - zoomControlsEnabled: false, - myLocationButtonEnabled: false, - onMapCreated: (GoogleMapController controller) async { - mapController.complete(controller); + onMapReady: (controller) async { if (hasRiderLocation && hasPickup) { - final bounds = LatLngBounds( - southwest: LatLng( - riderLat < pickupLat ? riderLat : pickupLat, - riderLng < pickupLng ? riderLng : pickupLng, + await controller.fitBounds( + LatLngBounds( + southwest: LatLng( + riderLat < pickupLat ? riderLat : pickupLat, + riderLng < pickupLng ? riderLng : pickupLng, + ), + northeast: LatLng( + riderLat > pickupLat ? riderLat : pickupLat, + riderLng > pickupLng ? riderLng : pickupLng, + ), ), - northeast: LatLng( - riderLat > pickupLat ? riderLat : pickupLat, - riderLng > pickupLng ? riderLng : pickupLng, - ), - ); - await controller.animateCamera( - CameraUpdate.newLatLngBounds(bounds, 80), + padding: 80, ); } }, diff --git a/lib/views/Dashboard/profile/informations/help_center.dart b/lib/views/Dashboard/profile/informations/help_center.dart index 3bae952..27dddeb 100644 --- a/lib/views/Dashboard/profile/informations/help_center.dart +++ b/lib/views/Dashboard/profile/informations/help_center.dart @@ -45,7 +45,7 @@ class HelpCenter extends StatelessWidget { ), const SizedBox(height: 8), Text( - "We make sure your delivery experience is smooth and clear. Whether you’re on your first trip or your hundredth, we’ve got your back. Browse through frequently asked questions or reach out directly if you need further help.", + "We make sure your delivery experience is smooth and clear. Whether you're on your first trip or your hundredth, we've got your back. Browse through frequently asked questions or reach out directly if you need further help.", style: TextStyle(fontSize: 18,color: Colors.grey.shade700, height: 1.4, fontFamily: FontConstants.fontFamily), ), const SizedBox(height: 16), diff --git a/pubspec.lock b/pubspec.lock index b8616c6..63594d2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -590,14 +590,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.33" - flutter_polyline_points: - dependency: "direct main" - description: - name: flutter_polyline_points - sha256: c775fe59fbcf1f925d611c039555c7f58ed6d9411747b7a2915bbd9c5e730a51 - url: "https://pub.dev" - source: hosted - version: "3.1.0" flutter_screenutil: dependency: "direct main" description: @@ -752,54 +744,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.3" - google_maps: - dependency: transitive - description: - name: google_maps - sha256: "5d410c32112d7c6eb7858d359275b2aa04778eed3e36c745aeae905fb2fa6468" - url: "https://pub.dev" - source: hosted - version: "8.2.0" - google_maps_flutter: - dependency: "direct main" - description: - name: google_maps_flutter - sha256: "819985697596a42e1054b5feb2f407ba1ac92262e02844a40168e742b9f36dca" - url: "https://pub.dev" - source: hosted - version: "2.14.0" - google_maps_flutter_android: - dependency: transitive - description: - name: google_maps_flutter_android - sha256: "3835f6ae5e8b8d4d454d913575069513c9f216e088b87aa5c18cb3610951c6b4" - url: "https://pub.dev" - source: hosted - version: "2.18.6" - google_maps_flutter_ios: - dependency: transitive - description: - name: google_maps_flutter_ios - sha256: "115b03c2e637e74d084a78c0e1faf42884fcd8e65d1a9ce58d909ca5493afa32" - url: "https://pub.dev" - source: hosted - version: "2.15.7" - google_maps_flutter_platform_interface: - dependency: transitive - description: - name: google_maps_flutter_platform_interface - sha256: e8b1232419fcdd35c1fdafff96843f5a40238480365599d8ca661dde96d283dd - url: "https://pub.dev" - source: hosted - version: "2.14.1" - google_maps_flutter_web: - dependency: transitive - description: - name: google_maps_flutter_web - sha256: d416602944e1859f3cbbaa53e34785c223fa0a11eddb34a913c964c5cbb5d8cf - url: "https://pub.dev" - source: hosted - version: "0.5.14+3" gsettings: dependency: transitive description: @@ -836,10 +780,10 @@ packages: dependency: transitive description: name: image - sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.5.4" + version: "4.8.0" image_picker: dependency: "direct main" description: @@ -968,6 +912,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.3.2" + maplibre_gl: + dependency: "direct main" + description: + name: maplibre_gl + sha256: b676f124a2fcf88c4dafedc7b462155e6396d61ce7af46354b4de11b45c9812f + url: "https://pub.dev" + source: hosted + version: "0.26.2" + maplibre_gl_platform_interface: + dependency: transitive + description: + name: maplibre_gl_platform_interface + sha256: "1f0ca8a99f03fa9434618ee21f4e42dd615830a7dd973e632b11c73ffec993a8" + url: "https://pub.dev" + source: hosted + version: "0.26.2" + maplibre_gl_web: + dependency: transitive + description: + name: maplibre_gl_web + sha256: bbf022f29ceef26d73f63e584819fbd02fbaf4eb1facf5234206c78936cf8f1c + url: "https://pub.dev" + source: hosted + version: "0.26.2" matcher: dependency: transitive description: @@ -1216,14 +1184,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.2" - sanitize_html: - dependency: transitive - description: - name: sanitize_html - sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989" - url: "https://pub.dev" - source: hosted - version: "2.1.0" scratcher: dependency: "direct main" description: @@ -1357,14 +1317,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" - stream_transform: - dependency: transitive - description: - name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" - source: hosted - version: "2.1.1" string_scanner: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cea98e5..ccef6b1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: nearle description: Nearle Rider - Smarter Deliveries, Powered by AI publish_to: "none" -version: 1.2.28+145 +version: 1.3.0+148 environment: @@ -32,12 +32,11 @@ dependencies: slide_to_submit_button: ^1.0.3 flutter_tts: ^4.0.2 vibration: ^3.1.4 - google_maps_flutter: ^2.13.1 + maplibre_gl: ^0.26.2 contained_tab_bar_view: ^0.8.0 tab_indicator_styler: ^2.0.0 geocoding: ^4.0.0 fl_chart: ^1.1.1 - flutter_polyline_points: ^3.1.0 battery_plus: ^7.0.0 provider: ^6.1.5+1 slider_button_lite: ^1.0.1 diff --git a/test/maps_smoke_test.dart b/test/maps_smoke_test.dart new file mode 100644 index 0000000..e95bd50 --- /dev/null +++ b/test/maps_smoke_test.dart @@ -0,0 +1,175 @@ +// Smoke test for the map/routing stack. +// +// This deliberately hits the LIVE endpoints the app uses, because the point is +// to verify the integration end to end — not to test mocks. It therefore needs +// network access and is not suitable for offline CI. +// +// flutter test test/maps_smoke_test.dart +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:nearle/services/maps/map_config.dart'; +import 'package:nearle/services/maps/map_marker_icons.dart'; +import 'package:nearle/services/maps/routing_service.dart'; +import 'package:maplibre_gl/maplibre_gl.dart' show LatLng; + +void main() { + // The binding is needed to rasterise marker icons, but it also installs an + // HttpOverrides that stubs every request with a 400. Clear it so the LIVE + // groups below actually reach the network. + TestWidgetsFlutterBinding.ensureInitialized(); + HttpOverrides.global = null; + + // Two real points in Coimbatore, where Nearle operates. + const origin = LatLng(11.0168, 76.9558); + const destination = LatLng(10.9984, 76.9776); + + group('polyline decoder (offline)', () { + test('decodes a known Google-encoded polyline', () { + // Canonical example from Google's encoded-polyline spec: + // (38.5,-120.2) (40.7,-120.95) (43.252,-126.453) + final points = RoutingService.decodePolyline('_p~iF~ps|U_ulLnnqC_mqNvxq`@'); + + expect(points.length, 3); + expect(points[0].latitude, closeTo(38.5, 0.00001)); + expect(points[0].longitude, closeTo(-120.2, 0.00001)); + expect(points[2].latitude, closeTo(43.252, 0.00001)); + expect(points[2].longitude, closeTo(-126.453, 0.00001)); + }); + + test('returns empty for garbage rather than throwing', () { + expect(RoutingService.decodePolyline(''), isEmpty); + expect(() => RoutingService.decodePolyline('!!!'), returnsNormally); + }); + }); + + group('marker icons (offline)', () { + test('renders PNG bytes with a stable cache key', () async { + final red = await MapMarkerIcons.pin(Colors.red); + final redAgain = await MapMarkerIcons.pin(Colors.red); + final green = await MapMarkerIcons.pin(Colors.green); + final dot = await MapMarkerIcons.dot(Colors.blueAccent); + final step = await MapMarkerIcons.numberedCircle( + 3, + primaryColor: Colors.deepPurple, + ); + + // PNG magic number. + for (final icon in [red, green, dot, step]) { + expect(icon.bytes.length, greaterThan(100)); + expect(icon.bytes.sublist(0, 4), [0x89, 0x50, 0x4E, 0x47]); + } + + expect(red.key, redAgain.key, reason: 'same colour must reuse the key'); + expect(identical(red.bytes, redAgain.bytes), isTrue, + reason: 'second call must come from cache'); + expect(red.key, isNot(green.key)); + expect(step.key, contains('3')); + }); + }); + + group('LIVE tile style', () { + test('style document is fetchable and MapLibre-shaped', () async { + final response = await http + .get(Uri.parse(MapConfig.styleUrl)) + .timeout(const Duration(seconds: 30)); + + expect(response.statusCode, 200, reason: MapConfig.styleUrl); + + final style = json.decode(response.body) as Map; + expect(style['version'], 8, reason: 'MapLibre style spec version'); + expect(style['sources'], isA()); + expect((style['layers'] as List), isNotEmpty); + + // No credential should be needed anywhere in the document. + expect( + response.body.contains('api_key='), + isFalse, + reason: 'tile style must not require a key', + ); + }, timeout: const Timeout(Duration(seconds: 60))); + }); + + group('LIVE routing', () { + setUp(RoutingService.clearCache); + + test('returns a plausible road route', () async { + final route = await RoutingService.getRoute( + origin: origin, + destination: destination, + ); + + expect(route, isNotNull, reason: 'no route from ${MapConfig.osrmBaseUrl}'); + route!; + + // ~2.5 km straight line; the road route must be longer but not absurd. + expect(route.distanceMeters, greaterThan(2000)); + expect(route.distanceMeters, lessThan(15000)); + expect(route.durationSeconds, greaterThan(0)); + + // Geometry must actually be decoded, not a two-point fallback. + expect(route.points.length, greaterThan(10)); + + // Route must start and end near the requested points, which also proves + // the lon,lat ordering in the request is right — swapped coordinates + // would land in the Indian Ocean. + expect(route.points.first.latitude, closeTo(origin.latitude, 0.02)); + expect(route.points.first.longitude, closeTo(origin.longitude, 0.02)); + expect(route.points.last.latitude, closeTo(destination.latitude, 0.02)); + expect(route.points.last.longitude, closeTo(destination.longitude, 0.02)); + }, timeout: const Timeout(Duration(seconds: 60))); + + test('multi-stop route is one call and visits every waypoint', () async { + const waypoint = LatLng(11.0075, 76.9660); + + final route = await RoutingService.getRoute( + origin: origin, + destination: destination, + waypoints: const [waypoint], + ); + + expect(route, isNotNull); + route!; + expect(route.points.length, greaterThan(10)); + + // The path should pass near the waypoint. + final passesNear = route.points.any( + (p) => + (p.latitude - waypoint.latitude).abs() < 0.01 && + (p.longitude - waypoint.longitude).abs() < 0.01, + ); + expect(passesNear, isTrue, reason: 'route ignored the waypoint'); + }, timeout: const Timeout(Duration(seconds: 60))); + + test('second identical request is served from cache', () async { + final first = await RoutingService.getRoute( + origin: origin, + destination: destination, + ); + expect(first, isNotNull); + + final watch = Stopwatch()..start(); + final second = await RoutingService.getRoute( + origin: origin, + destination: destination, + ); + watch.stop(); + + expect(identical(first, second), isTrue, + reason: 'cache must return the same instance'); + expect(watch.elapsedMilliseconds, lessThan(50), + reason: 'cache hit should not touch the network'); + }, timeout: const Timeout(Duration(seconds: 60))); + + test('invalid coordinates fail closed instead of throwing', () async { + final route = await RoutingService.getRoute( + origin: const LatLng(0, 0), + destination: destination, + ); + expect(route, isNull); + }); + }); +} diff --git a/tool/compare_route_distances.py b/tool/compare_route_distances.py new file mode 100755 index 0000000..6f4b2b4 --- /dev/null +++ b/tool/compare_route_distances.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Compare OSRM road distances against previously-recorded (Google) distances. + +Routing distance feeds `riderkms` -> `ridercharges`, so a systematic difference +between the old and new routing backend changes what riders get paid. Run this +against a sample of completed deliveries BEFORE rolling out. + +Input CSV (header required), one row per completed delivery: + + origin_lat,origin_lng,dest_lat,dest_lng,recorded_km + 11.0168,76.9558,10.9984,76.9776,4.6 + 11.0510,76.9910,11.0168,76.9558,6.1 + +Usage: + + python3 tool/compare_route_distances.py deliveries.csv + python3 tool/compare_route_distances.py deliveries.csv \\ + --osrm https://osrm.your-domain.in +""" + +import argparse +import csv +import json +import statistics +import subprocess +import sys +import urllib.parse +import urllib.request + +DEFAULT_OSRM = "https://routing.openstreetmap.de/routed-car" +USER_AGENT = "nearle-distance-check" + + +def _fetch(url, timeout): + """GET a URL, falling back to curl when Python's SSL stack is too old. + + Some systems ship a Python linked against an OpenSSL that fails the + handshake against modern servers; curl on the same box usually works. + """ + try: + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read().decode() + except Exception: # noqa: BLE001 + result = subprocess.run( + ["curl", "-sS", "--max-time", str(timeout), "-A", USER_AGENT, url], + capture_output=True, text=True, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "curl failed") from None + return result.stdout + + +def osrm_km(base, o_lat, o_lng, d_lat, d_lng, timeout=25): + """Road distance in km, or None. Note OSRM takes lon,lat.""" + url = ( + f"{base}/route/v1/driving/" + f"{o_lng},{o_lat};{d_lng},{d_lat}" + f"?{urllib.parse.urlencode({'overview': 'false'})}" + ) + data = json.loads(_fetch(url, timeout)) + if data.get("code") != "Ok" or not data.get("routes"): + return None + return data["routes"][0]["distance"] / 1000.0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("csv_path", help="CSV of completed deliveries") + ap.add_argument("--osrm", default=DEFAULT_OSRM, + help=f"OSRM base URL (default: {DEFAULT_OSRM})") + args = ap.parse_args() + + deltas, failures = [], 0 + + print(f"{'recorded':>10} {'osrm':>10} {'delta':>10} {'pct':>8}") + print("-" * 42) + + with open(args.csv_path, newline="") as fh: + for row in csv.DictReader(fh): + try: + recorded = float(row["recorded_km"]) + km = osrm_km( + args.osrm, + float(row["origin_lat"]), float(row["origin_lng"]), + float(row["dest_lat"]), float(row["dest_lng"]), + ) + except Exception as exc: # noqa: BLE001 + print(f" ERROR: {exc}", file=sys.stderr) + failures += 1 + continue + + if km is None: + print(" no route found", file=sys.stderr) + failures += 1 + continue + + delta = km - recorded + pct = (delta / recorded * 100) if recorded else 0.0 + deltas.append(pct) + print(f"{recorded:9.2f}k {km:9.2f}k {delta:+9.2f}k {pct:+7.1f}%") + + if not deltas: + print("\nNo comparable rows.", file=sys.stderr) + return 1 + + mean = statistics.fmean(deltas) + median = statistics.median(deltas) + print("-" * 42) + print(f"rows compared : {len(deltas)} (failed: {failures})") + print(f"mean delta : {mean:+.1f}%") + print(f"median delta : {median:+.1f}%") + if len(deltas) > 1: + print(f"std dev : {statistics.stdev(deltas):.1f}%") + + print() + if abs(median) < 3: + print("OK — no meaningful bias. Rider pay is unaffected.") + elif abs(median) < 8: + print("MILD BIAS — acceptable, but tell finance the average trip " + f"distance shifts {median:+.1f}%.") + else: + print(f"SIGNIFICANT BIAS ({median:+.1f}%) — investigate before rollout. " + "Usually means missing OSM roads in your service area; consider " + "contributing the missing roads to OpenStreetMap, which fixes it " + "permanently and for everyone.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())