diff --git a/.gitignore b/.gitignore index 3820a95..e2a9806 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,11 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Widget Preview related +.widget_preview/ + +# Android signing — never commit these +android/key.properties +**/*.jks +**/*.keystore diff --git a/.metadata b/.metadata index b0f490a..483d16e 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "924134a44c189315be2148659913dda1671cbe99" + revision: "d3b14c876900e553bc736ca19295fc09e3853e8e" channel: "stable" project_type: app @@ -13,20 +13,17 @@ project_type: app migration: platforms: - platform: root - create_revision: 924134a44c189315be2148659913dda1671cbe99 - base_revision: 924134a44c189315be2148659913dda1671cbe99 + create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e - platform: android - create_revision: 924134a44c189315be2148659913dda1671cbe99 - base_revision: 924134a44c189315be2148659913dda1671cbe99 + create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e - platform: ios - create_revision: 924134a44c189315be2148659913dda1671cbe99 - base_revision: 924134a44c189315be2148659913dda1671cbe99 - - platform: macos - create_revision: 924134a44c189315be2148659913dda1671cbe99 - base_revision: 924134a44c189315be2148659913dda1671cbe99 + create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e - platform: web - create_revision: 924134a44c189315be2148659913dda1671cbe99 - base_revision: 924134a44c189315be2148659913dda1671cbe99 + create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e + base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e # User provided section diff --git a/README.md b/README.md index 7fb0cb9..b5a889b 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,321 @@ -# Doormile — EV-First Parcel Delivery (Flutter) +# Doormile — Customer App -A complete, fully-navigable Flutter app built from the Doormile design brief. -EV-first delivery, MileTruth AI routing narrative, corporate-red design system. +A Flutter app for the Doormile customer journey: -## What's inside (all screens implemented) +**Book pickup → Track → Delivered** -| Flow | Screens | -|------|---------| -| Onboarding | Splash → 3-slide Onboarding | -| Auth | Login (phone) → OTP verify | -| Home | Home hub (hero, quick actions, active shipment, eco banner) | -| Booking | Pickup/Drop → Parcel details & slot → Service & price → Review → Payment sheet → Success | -| Tracking | My Parcels (filters) → Live Tracking (map + rider) → Shipment Journey timeline | -| Wallet | Balance card, add money, saved methods, transactions | -| Profile | Stats, settings list, logout | -| Support | FAQ accordion + chat assistant | - -Everything is wired with real navigation and in-memory state (Provider): -placing an order adds it to **My Parcels**, paying by wallet deducts the balance, -"Add money" updates the wallet, logout returns to login, etc. - -## Design system - -- **Colours / type / spacing** mirror `DESIGN.md` (primary `#960019`, Public Sans). -- Public Sans is **bundled** (`assets/fonts/PublicSans.ttf`) — no network fonts. -- Reusable widgets in `lib/widgets/` (cards, buttons, chips, progress bars). - -## How to run - -Open the Terminal app, then (the `flutter` tool lives in Homebrew): - -```bash -export PATH="/opt/homebrew/bin:$PATH" -cd /Users/apple/Desktop/Doormile-app/doormile - -# Pick ONE target: -flutter run -d chrome # opens in Chrome (fastest, no setup) -flutter run -d macos # native macOS window -flutter run # phone/emulator if one is connected -``` - -Demo tips: at login enter **any 10-digit number**, and at OTP enter **any 6 digits** — -there's no backend, it's a front-end prototype. - -## Project layout +State is a `ChangeNotifier` behind an `InheritedNotifier`; navigation is +`Navigator` with named booking steps. Real maps come from `flutter_map` over +OpenStreetMap/CARTO tiles and real positions from `geolocator` — no API key and +no billing account required. Everything else is the Flutter SDK. ``` lib/ - main.dart # app entry, theme, named routes - theme/ # colours + text styles (design tokens) - models/ # Order, AddressEntry, WalletTransaction, ... - state/app_state.dart # Provider store + mock data + booking draft - widgets/ # shared UI (AppCard, PrimaryButton, StatusChip, logo) - screens/ # one file per screen, grouped by flow -test/ # smoke, full-journey, and per-screen render tests + main.dart app entry, theme, page transitions + data/ + models.dart Booking, Destination, DeliveryDetails, JourneyStage… + doormile_api.dart the service surface — one implementation + live_doormile_api.dart ← the ONLY place the app talks to a server + location_service.dart ← the ONLY place device GPS is touched + map_config.dart ← the ONLY place tile URLs, attribution + keys live + state/ + app_state.dart single source of truth + stage transitions + app_scope.dart InheritedNotifier plumbing + ui/ + tokens.dart colours, radii, type scale, shadows (Inter) + format.dart greeting / relative time / date labels + widgets/ buttons, chrome, inputs, option tiles, milestones… + map_tiles.dart config → flutter_map layer, + attribution + map_panel.dart real pannable map, pin locked to the centre + route_map.dart real route map: polyline, hubs, live Miler + screens/ + auth/ login · sign up · OTP (shared brand scaffold) + shell_screen.dart Home · Orders · Account bottom navigation + home_screen.dart orders_screen.dart account_screen.dart + order_details_screen.dart receipt for a finished order + tracking_screen.dart live tracking, driven by stage + booking/ pickup → destination → time → review → confirmed + sheets/ place search, delivery details, cancel booking +store/ Play Console listing assets ``` +## The product model this encodes + +The customer creates a **pickup booking**, not a shipment. + +Booking is four steps: + +``` +Home ▸ Book a Pickup + 1. Pickup point map pin, pre-filled from the device location + 2. Destination state → district, then "How many packages?" (defaults to 1) + "+ Send to another place" adds another destination + 3. Pickup time windows the backend still has capacity for + 4. Review one line per place, combined estimate, Book Pickup +``` + +**One pickup booking → one or more destinations → order(s) created after +collection.** A single Miler visit can collect packages bound for several +districts; each destination becomes its own tracked order only once the Miler +completes the pickup. The customer never sees carrier vocabulary — no +consignment, no waybill, no AWB. + +* **Required:** pickup location, and for each destination a serviceable + **state** + **district**. Package count defaults to 1. +* **Optional:** exact street, landmark, recipient name and phone, delivery + instructions — per destination, in a sheet opened from the destination card + or Review. Never blocking, and shown as + *"Not added — the Miler can confirm this at pickup."* +* **Weight is never asked.** The Miler verifies it at pickup, which is when the + price settles. +* **Caps** come from `BookingLimits` (backend-served, defaults 20 packages / + 5 destinations per pickup). Nothing in the UI hardcodes them. +* **Cancellation** is whole-pickup only, and only while + `DoormileApi.isCancellable(stage)` allows it. + +A customer sending one package to one district sees exactly what they saw +before: pick a place, tap Next. The multi-destination machinery appears only +when they ask for it. + +Stages shown to the customer (`JourneyStage`; raw backend keys never reach a +screen): + +``` +Pickup booked → Miler assigned → Miler on the way → Miler arrived → +Package picked up → Order created → In transit → Out for delivery → Delivered +``` + +**Cancellation** is offered only while `DoormileApi.isCancellable(stage)` is +true — through *Miler arrived*. Once the Miler has custody the footer becomes a +support action instead. + +## Maps and location + +Both maps are real. `flutter_map` renders OpenStreetMap data through CARTO's +basemaps — no API key, no billing, no per-request cost to develop against. + +**No widget knows a tile URL.** `lib/data/map_config.dart` is the only place a +template, a subdomain list, an attribution string or a map API key exists; +`lib/ui/widgets/map_tiles.dart` turns that into a layer. Changing provider is a +build flag, never a map-screen edit — and a test fails the build if a URL ever +reappears under `lib/ui/`. + +* **Pickup** (`DmMapPanel`) — the pin is welded to the centre of the map and the + map moves under it. When it settles, the coordinates under the pin are + reverse-geocoded and the address appears in the box below, inside the same + card. Drag, and the address follows. +* **The customer's own position** is a blue dot with an accuracy halo drawn at + true map scale, so it shrinks as they zoom in. It is a real coordinate, not a + screen overlay: dragging moves the crimson pickup pin and leaves the dot + where the customer actually is, which is how they judge how far they have + moved the pin from themselves. No fix means no dot — a dot in the wrong place + is worse than none. +* **Route** (`DmRouteMap`) — pickup and destination hub as real markers joined + by the transit arc, with the Miler's live position along it. Non-interactive + on purpose: it summarises a booking and must never steal a scroll gesture. +* **Position** (`LocationService`) — the only place `geolocator` is touched. + Every platform call is bounded by a timeout and every failure comes back as a + typed `LocationDenial`, so a refused permission or a wedged plugin can never + freeze the booking flow. It falls back to the service-area centre and says + why. + +### Choosing a tile provider + +CARTO is the development default and is *not* assumed to be the production +contract. Everything is set at build time: + +```bash +# CARTO with a proper API key — the likely production step +flutter build appbundle --dart-define=DM_MAP_KEY=$CARTO_API_KEY + +# a different vendor +flutter run --dart-define=DM_MAP_PROVIDER=maptiler --dart-define=DM_MAP_KEY=… +flutter run --dart-define=DM_MAP_PROVIDER=stadia --dart-define=DM_MAP_KEY=… + +# self-hosted or anything else +flutter run \ + --dart-define=DM_MAP_PROVIDER=custom \ + --dart-define=DM_MAP_URL=https://tiles.doormile.in/streets/{z}/{x}/{y}.png \ + --dart-define=DM_MAP_URL_MUTED=https://tiles.doormile.in/light/{z}/{x}/{y}.png \ + --dart-define=DM_MAP_ATTRIBUTION='© Doormile · © OpenStreetMap contributors' +``` + +| Define | Effect | +| --- | --- | +| `DM_MAP_PROVIDER` | `carto` (default) · `osm` · `maptiler` · `stadia` · `custom` | +| `DM_MAP_KEY` | Appended as the provider's key parameter (`api_key`, `key`) | +| `DM_MAP_URL` / `DM_MAP_URL_MUTED` | Templates for `custom` | +| `DM_MAP_SUBDOMAINS` | Comma-separated `{s}` shards | +| `DM_MAP_ATTRIBUTION` / `_URL` | Credit line and licence link | +| `DM_MAP_MAX_ZOOM` | Deepest native zoom the provider serves | +| `DM_APP_VERSION` | Goes into the `User-Agent` | + +A provider that needs a key but was given none falls back to CARTO with a +`warning` on the config rather than rendering a blank map. Every tile request +carries `User-Agent: Doormile/ (in.doormile.customer)`, so the provider +can attribute and rate-limit our traffic properly. `keepBuffer`/`panBuffer` stay +at flutter_map's defaults — the app fetches the tiles it is about to draw and +nothing more. There is no bulk prefetch or offline tile scraping, by design. + +The credit line is rendered on **every** map, its text taken from whichever +provider is configured, so it stays correct after a swap. + +`ACCESS_FINE_LOCATION` / `ACCESS_COARSE_LOCATION` are declared on Android and +`NSLocationWhenInUseUsageDescription` on iOS; GPS hardware is marked optional so +Play does not filter the app off devices without it. + + +## Running it + +```bash +flutter pub get +flutter run # any connected device or simulator +flutter test # 79 tests covering the whole flow +``` + +`flutter run` talks to `https://api.doormile.com/api/v1`. Point it somewhere +else with a define: + +```bash +flutter run --dart-define=DM_API_BASE=https:///api/v1 +``` + +### Signing in for development + +A shipping build has **one** API implementation, `LiveDoormileApi`, and it talks +to a server. Nothing invented can reach a customer. + +But the real backend has no SMS gateway yet, so the OTP never arrives and you +cannot sign in against it. Two dev-only ways in, both `!kReleaseMode` — no define +can put either in a release build, and the Account screen prints the mode it is +in: + +**Offline dev data** — the whole app, no server, any 4-digit code signs in: + +```bash +flutter run --dart-define=DM_MOCK=true +# login screen → 9876543210 → 1234 → in. +# add --dart-define=DM_DEV_LOGIN=true to skip the login screen entirely. +``` + +This swaps in `DevDoormileApi`: in-memory serviceability, slots, fare and +bookings, so you can walk signup → book → track without a backend. **A booking +made here stays on the device — it reaches no server and no admin console.** The +Account screen reads `DEV DATA (offline)`. + +**Real token** — talk to the live server, skip only the OTP round trip. Not a +bypass: the server authorises every request it signs, and a revoked token signs +the build straight back out: + +```bash +flutter run --dart-define=DM_DEV_TOKEN=eyJ... \ + --dart-define=DM_DEV_REFRESH_TOKEN=... +``` + +### Walking a booking through its stages + +Against dev data, the tracking screen's stepper walks the in-memory booking. +Against a non-production backend it drives the server's own QA helper +(`POST /customer/ops/bookings/{ref}/stage`) — each tap posts and re-reads, so +what appears is the stage the **server** recorded: + +```bash +flutter run --dart-define=DM_API_BASE=https:///api/v1 \ + --dart-define=DM_ALLOW_STAGE_OVERRIDE=true +``` + +The backend gates it too, with `CX_ALLOW_STAGE_OVERRIDE`. The app refuses to +send it on production regardless. + +### Walking a booking through its stages + +On a non-production backend, the tracking screen can offer a stepper that drives +the server's own QA helper (`POST /customer/ops/bookings/{ref}/stage`). Each tap +posts and then re-reads the booking, so what appears is the stage the **server** +recorded: + +```bash +flutter run --dart-define=DM_API_BASE=https:///api/v1 \ + --dart-define=DM_ALLOW_STAGE_OVERRIDE=true +``` + +The backend gates it too, with `CX_ALLOW_STAGE_OVERRIDE`. The app refuses to +send it on production regardless. + +## The backend + +Every screen's data comes from `lib/data/live_doormile_api.dart`, which is a +thin translation between the app's models and the wire contract — the transport +concerns (envelope, error codes, bearer auth and its silent refresh, +idempotency, `ETag`, keyset paging, retries) all live in `api_client.dart`. + +`DmAsyncList` wraps every backend-driven list, so loading skeletons, empty +states and retry-on-error come with any new endpoint for free. + +`District` carries `lat`/`lng` for its hub, which is what the route map draws +to; without them it simply draws the pickup end. + +Still to wire: push notifications (`POST /customer/devices` is implemented but +no SDK produces a token), deep links (`GET /customer/orders/{trackingId}` is +implemented but no intent filter or URL type is registered), and a post-booking +edit screen for `PATCH …/destinations/{i}`. The contract is in +[`docs/API_READINESS.md`](docs/API_READINESS.md). + +## Releasing to Google Play + +The app is configured for a Play upload: application ID `in.doormile.customer`, +label **Doormile**, portrait only, `INTERNET` declared in the main manifest, +adaptive + monochrome launcher icon, an Android 12+ splash screen, R8 shrinking +on release, and backup/device-transfer disabled. + +1. **Create the upload key** (once): + + ```bash + keytool -genkey -v -keystore android/upload-keystore.jks \ + -keyalg RSA -keysize 2048 -validity 10000 -alias upload + ``` + +2. **Point Gradle at it**: copy `android/key.properties.example` to + `android/key.properties` and fill in the four values. Both the keystore and + `key.properties` are git-ignored — never commit them. Without this file the + release build falls back to debug signing so `flutter run --release` keeps + working locally. + +3. **Set the version** in `pubspec.yaml` (`version: 1.0.0+1` → `versionName` + + `versionCode`); bump the build number for every upload. + +4. **Build the bundle**: + + ```bash + flutter build appbundle --release + # build/app/outputs/bundle/release/app-release.aab + ``` + +5. **Upload** to Play Console. Listing assets are in `store/`: + `play_store_icon_512.png` and `play_feature_graphic_1024x500.png`. You still + need phone screenshots, a short/full description, a privacy policy URL, the + data-safety form (this build collects nothing — no analytics, no third-party + SDKs) and a content rating. + +A JDK 17 is required for Android builds (`flutter doctor` will confirm) — the +one bundled with Android Studio works: +`flutter config --jdk-dir "/Applications/Android Studio.app/Contents/jbr/Contents/Home"`. + ## Tests -```bash -flutter test # 18 tests: boot, full user journey, every screen renders clean -``` +The test suite is the only place a fake backend exists: +`test/support/fake_doormile_api.dart`, injected through +`DoormileApi.overrideInstance`. It owns its bookings and its stage machine +exactly as the server does — no test reaches into `AppState` to plant one. + +`test/live_api_wire_test.dart` checks what the app actually puts on the wire, +field by field, against the customer API document. + +`test/booking_flow_test.dart` drives the app end to end: signing in through the +OTP screen; dragging the pickup map and watching the address re-resolve; a +refused location falling back without blocking the booking; booking one package to one district with every optional field +skipped; four packages to one place; three destinations with mixed counts and +the review total; the package cap and destination cap; state→district +serviceability filtering; packages staying together until collection and then +splitting into their own orders; cancelling; a delivered order's receipt; and +the empty/error/retry service-area states. diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d29021..0619dcf 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,6 +9,13 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fbc7652..f6f3939 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,22 +1,25 @@ import java.util.Properties -import java.io.FileInputStream - -val keystorePropertiesFile = rootProject.file("key.properties") -val keystoreProperties = Properties() -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(FileInputStream(keystorePropertiesFile)) -} plugins { id("com.android.application") - id("org.jetbrains.kotlin.android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") - id("com.google.gms.google-services") } +// Upload-key credentials live outside version control. Copy +// `android/key.properties.example` to `android/key.properties` and fill it in +// before building a release bundle; without it we fall back to debug signing so +// `flutter run --release` still works on a developer machine. +val keystoreProperties = Properties().apply { + val file = rootProject.file("key.properties") + if (file.exists()) { + file.inputStream().use { load(it) } + } +} +val hasUploadKey = keystoreProperties.getProperty("storeFile") != null + android { - namespace = "com.doormile.customer" + namespace = "in.doormile.customer" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -26,33 +29,46 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.doormile.customer" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion + applicationId = "in.doormile.customer" + minSdk = 24 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } signingConfigs { - create("release") { - keyAlias = keystoreProperties["keyAlias"] as String? - keyPassword = keystoreProperties["keyPassword"] as String? - storeFile = keystoreProperties["storeFile"]?.let { file(it as String) } - storePassword = keystoreProperties["storePassword"] as String? + if (hasUploadKey) { + create("release") { + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + storeFile = rootProject.file(keystoreProperties.getProperty("storeFile")) + storePassword = keystoreProperties.getProperty("storePassword") + } } } buildTypes { release { - signingConfig = if (keystorePropertiesFile.exists()) { + signingConfig = if (hasUploadKey) { signingConfigs.getByName("release") } else { signingConfigs.getByName("debug") } + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) } + debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + } + } + + bundle { + language { enableSplit = false } } } @@ -65,10 +81,3 @@ kotlin { flutter { source = "../.." } - -dependencies { - // Import the Firebase BoM - implementation(platform("com.google.firebase:firebase-bom:34.14.1")) - // Add the dependencies for Firebase products you want to use - implementation("com.google.firebase:firebase-analytics") -} diff --git a/android/app/google-services.json b/android/app/google-services.json deleted file mode 100644 index 776587f..0000000 --- a/android/app/google-services.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "project_info": { - "project_number": "923472686173", - "project_id": "doormilecustomer", - "storage_bucket": "doormilecustomer.firebasestorage.app" - }, - "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:923472686173:android:175b38552da45f2d4ef1b4", - "android_client_info": { - "package_name": "com.doormile.customer" - } - }, - "oauth_client": [], - "api_key": [ - { - "current_key": "AIzaSyAoxaF4fmYXU372qZkYAo7WftFqgiEg_7k" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [] - } - } - } - ], - "configuration_version": "1" -} \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 7ff2f16..96306fc 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1,24 +1,3 @@ -# Flutter Wrapper --keep class io.flutter.app.** { *; } --keep class io.flutter.plugin.** { *; } --keep class io.flutter.util.** { *; } --keep class io.flutter.view.** { *; } --keep class io.flutter.** { *; } --keep class io.flutter.plugins.** { *; } - -# Firebase --keep class com.google.firebase.** { *; } - -# Flutter Deferred Components (Play Core) --dontwarn com.google.android.play.core.** - -# Add project specific ProGuard rules here. -# By default, the flags in this file are appended to flags specified -# in C:\sdk\tools\proguard\proguard-android.txt -# You can edit the include path and order by changing the proguardFiles -# directive in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# Add any project specific keep options here: +# Flutter's own rules are applied by the Flutter Gradle plugin. Keep this file +# for app-specific rules — e.g. models used with reflection by a JSON codec. +-keep class in.doormile.customer.** { *; } diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f698..edbb895 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,7 +1,17 @@ - + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c5764b5..2561c56 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,13 +1,29 @@ + + + - - + + + + + android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher_round" + android:enableOnBackInvokedCallback="true" + android:allowBackup="false" + android:fullBackupContent="false" + android:dataExtractionRules="@xml/data_extraction_rules"> - - - - diff --git a/android/app/src/main/kotlin/com/doormile/customer/MainActivity.kt b/android/app/src/main/kotlin/in/doormile/customer/MainActivity.kt similarity index 75% rename from android/app/src/main/kotlin/com/doormile/customer/MainActivity.kt rename to android/app/src/main/kotlin/in/doormile/customer/MainActivity.kt index 22837d1..b1195d5 100644 --- a/android/app/src/main/kotlin/com/doormile/customer/MainActivity.kt +++ b/android/app/src/main/kotlin/in/doormile/customer/MainActivity.kt @@ -1,4 +1,4 @@ -package com.doormile.customer +package `in`.doormile.customer import io.flutter.embedding.android.FlutterActivity diff --git a/android/app/src/main/res/drawable-hdpi/android12splash.png b/android/app/src/main/res/drawable-hdpi/android12splash.png deleted file mode 100644 index 9d7eec9..0000000 Binary files a/android/app/src/main/res/drawable-hdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png deleted file mode 100644 index 9d7eec9..0000000 Binary files a/android/app/src/main/res/drawable-hdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/android12splash.png b/android/app/src/main/res/drawable-mdpi/android12splash.png deleted file mode 100644 index 9a2c0a5..0000000 Binary files a/android/app/src/main/res/drawable-mdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png deleted file mode 100644 index 9a2c0a5..0000000 Binary files a/android/app/src/main/res/drawable-mdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-hdpi/android12splash.png b/android/app/src/main/res/drawable-night-hdpi/android12splash.png deleted file mode 100644 index 9d7eec9..0000000 Binary files a/android/app/src/main/res/drawable-night-hdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-mdpi/android12splash.png b/android/app/src/main/res/drawable-night-mdpi/android12splash.png deleted file mode 100644 index 9a2c0a5..0000000 Binary files a/android/app/src/main/res/drawable-night-mdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xhdpi/android12splash.png deleted file mode 100644 index 3e7236c..0000000 Binary files a/android/app/src/main/res/drawable-night-xhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png deleted file mode 100644 index cd98261..0000000 Binary files a/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png deleted file mode 100644 index c894515..0000000 Binary files a/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png deleted file mode 100644 index 8e21404..0000000 Binary files a/android/app/src/main/res/drawable-v21/background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index 3cc4948..d863c0b 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,9 +1,11 @@ + - - - - - - + + diff --git a/android/app/src/main/res/drawable-xhdpi/android12splash.png b/android/app/src/main/res/drawable-xhdpi/android12splash.png deleted file mode 100644 index 3e7236c..0000000 Binary files a/android/app/src/main/res/drawable-xhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png deleted file mode 100644 index 3e7236c..0000000 Binary files a/android/app/src/main/res/drawable-xhdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxhdpi/android12splash.png deleted file mode 100644 index cd98261..0000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png deleted file mode 100644 index cd98261..0000000 Binary files a/android/app/src/main/res/drawable-xxhdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png b/android/app/src/main/res/drawable-xxxhdpi/android12splash.png deleted file mode 100644 index c894515..0000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/android12splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png deleted file mode 100644 index c894515..0000000 Binary files a/android/app/src/main/res/drawable-xxxhdpi/splash.png and /dev/null differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png deleted file mode 100644 index 8e21404..0000000 Binary files a/android/app/src/main/res/drawable/background.png and /dev/null differ diff --git a/android/app/src/main/res/drawable/dm_mark_brand.xml b/android/app/src/main/res/drawable/dm_mark_brand.xml new file mode 100644 index 0000000..b73f320 --- /dev/null +++ b/android/app/src/main/res/drawable/dm_mark_brand.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/dm_mark_white.xml b/android/app/src/main/res/drawable/dm_mark_white.xml new file mode 100644 index 0000000..c3aecbb --- /dev/null +++ b/android/app/src/main/res/drawable/dm_mark_white.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 3cc4948..d863c0b 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,9 +1,11 @@ + - - - - - - + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c608ac2 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..c608ac2 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..e111dc5 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png b/android/app/src/main/res/mipmap-hdpi/launcher_icon.png deleted file mode 100644 index c00f9f5..0000000 Binary files a/android/app/src/main/res/mipmap-hdpi/launcher_icon.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..bdb7152 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png b/android/app/src/main/res/mipmap-mdpi/launcher_icon.png deleted file mode 100644 index 37a9220..0000000 Binary files a/android/app/src/main/res/mipmap-mdpi/launcher_icon.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..c290cf8 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png deleted file mode 100644 index 910882d..0000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..92556da 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png deleted file mode 100644 index 82a9ded..0000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..5fe6a29 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png b/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png deleted file mode 100644 index dc5e36c..0000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png and /dev/null differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml index 2df1a97..2ee3677 100644 --- a/android/app/src/main/res/values-night-v31/styles.xml +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -1,21 +1,13 @@ - - - - diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index dbc9ea9..4c60046 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -1,22 +1,11 @@ - - - - diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml index dbdf6f9..2ee3677 100644 --- a/android/app/src/main/res/values-v31/styles.xml +++ b/android/app/src/main/res/values-v31/styles.xml @@ -1,21 +1,13 @@ - + - diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..1b9df4a --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + + #960019 + #FFFFFF + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 0d1fa8f..4c60046 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,22 +1,11 @@ - + - + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..fe2ccdf --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + diff --git a/android/gradle.properties b/android/gradle.properties index 3622f12..e96108c 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,10 +1,6 @@ -org.gradle.jvmargs=-Xmx2560m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 -org.gradle.parallel=false -org.gradle.workers.max=1 -org.gradle.daemon=false +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true # This newDsl flag was added by the Flutter template android.newDsl=false # This builtInKotlin flag was added by the Flutter template android.builtInKotlin=false -kotlin.incremental=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 2d428bf..a20f2c4 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip diff --git a/android/key.properties.example b/android/key.properties.example new file mode 100644 index 0000000..1371136 --- /dev/null +++ b/android/key.properties.example @@ -0,0 +1,6 @@ +# Copy to android/key.properties and fill in before `flutter build appbundle`. +# Never commit the real file or the .jks keystore. +storePassword=your-keystore-password +keyPassword=your-key-password +keyAlias=upload +storeFile=upload-keystore.jks diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 27ce201..b28021a 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -19,9 +19,8 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "9.0.1" apply false - id("org.jetbrains.kotlin.android") version "2.3.20" apply false - id("com.google.gms.google-services") version "4.4.4" apply false + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false } include(":app") diff --git a/assets/fonts/GeistMono-LICENSE.txt b/assets/fonts/GeistMono-LICENSE.txt new file mode 100644 index 0000000..61f6f4b --- /dev/null +++ b/assets/fonts/GeistMono-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/assets/fonts/GeistMono-VF.ttf b/assets/fonts/GeistMono-VF.ttf new file mode 100644 index 0000000..173867d Binary files /dev/null and b/assets/fonts/GeistMono-VF.ttf differ diff --git a/assets/fonts/Manrope-LICENSE.txt b/assets/fonts/Manrope-LICENSE.txt new file mode 100644 index 0000000..472064a --- /dev/null +++ b/assets/fonts/Manrope-LICENSE.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Manrope Project Authors (https://github.com/sharanda/manrope) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/assets/fonts/Manrope-VF.ttf b/assets/fonts/Manrope-VF.ttf new file mode 100644 index 0000000..23dcf5e Binary files /dev/null and b/assets/fonts/Manrope-VF.ttf differ diff --git a/assets/fonts/PublicSans.ttf b/assets/fonts/PublicSans.ttf deleted file mode 100644 index c747e1d..0000000 Binary files a/assets/fonts/PublicSans.ttf and /dev/null differ diff --git a/assets/images/CreateMpin.png b/assets/images/CreateMpin.png deleted file mode 100644 index 8bb3c08..0000000 Binary files a/assets/images/CreateMpin.png and /dev/null differ diff --git a/assets/images/Gps.png b/assets/images/Gps.png deleted file mode 100644 index bd29205..0000000 Binary files a/assets/images/Gps.png and /dev/null differ diff --git a/assets/images/Introsplashscreen.png b/assets/images/Introsplashscreen.png deleted file mode 100644 index debe563..0000000 Binary files a/assets/images/Introsplashscreen.png and /dev/null differ diff --git a/assets/images/Mpin.png b/assets/images/Mpin.png deleted file mode 100644 index aa929c3..0000000 Binary files a/assets/images/Mpin.png and /dev/null differ diff --git a/assets/images/Nearle Bike.png b/assets/images/Nearle Bike.png deleted file mode 100644 index 8f17e40..0000000 Binary files a/assets/images/Nearle Bike.png and /dev/null differ diff --git a/assets/images/_icon_preview.png b/assets/images/_icon_preview.png deleted file mode 100644 index ac86c5a..0000000 Binary files a/assets/images/_icon_preview.png and /dev/null differ diff --git a/assets/images/cancel.png b/assets/images/cancel.png deleted file mode 100644 index 64d20aa..0000000 Binary files a/assets/images/cancel.png and /dev/null differ diff --git a/assets/images/customermap.png b/assets/images/customermap.png deleted file mode 100644 index 72fc07c..0000000 Binary files a/assets/images/customermap.png and /dev/null differ diff --git a/assets/images/deliveryicon.png b/assets/images/deliveryicon.png deleted file mode 100644 index 750dff5..0000000 Binary files a/assets/images/deliveryicon.png and /dev/null differ diff --git a/assets/images/doormile-logistics-launcher.png b/assets/images/doormile-logistics-launcher.png deleted file mode 100644 index 1bee52b..0000000 Binary files a/assets/images/doormile-logistics-launcher.png and /dev/null differ diff --git a/assets/images/gps-navigation.png b/assets/images/gps-navigation.png deleted file mode 100644 index 6c34078..0000000 Binary files a/assets/images/gps-navigation.png and /dev/null differ diff --git a/assets/images/homeicon.png b/assets/images/homeicon.png deleted file mode 100644 index 015c931..0000000 Binary files a/assets/images/homeicon.png and /dev/null differ diff --git a/assets/images/in.png b/assets/images/in.png deleted file mode 100644 index a6b9737..0000000 Binary files a/assets/images/in.png and /dev/null differ diff --git a/assets/images/information-point.png b/assets/images/information-point.png deleted file mode 100644 index cdb28ee..0000000 Binary files a/assets/images/information-point.png and /dev/null differ diff --git a/assets/images/intro.png b/assets/images/intro.png deleted file mode 100644 index d1a5bdf..0000000 Binary files a/assets/images/intro.png and /dev/null differ diff --git a/assets/images/intro2.png b/assets/images/intro2.png deleted file mode 100644 index c1b0549..0000000 Binary files a/assets/images/intro2.png and /dev/null differ diff --git a/assets/images/intro4.png b/assets/images/intro4.png deleted file mode 100644 index a819dd4..0000000 Binary files a/assets/images/intro4.png and /dev/null differ diff --git a/assets/images/launcher_doormile.png b/assets/images/launcher_doormile.png deleted file mode 100644 index 475ee8f..0000000 Binary files a/assets/images/launcher_doormile.png and /dev/null differ diff --git a/assets/images/launcher_icon.png b/assets/images/launcher_icon.png deleted file mode 100644 index 1bee52b..0000000 Binary files a/assets/images/launcher_icon.png and /dev/null differ diff --git a/assets/images/map.png b/assets/images/map.png deleted file mode 100644 index e741e2c..0000000 Binary files a/assets/images/map.png and /dev/null differ diff --git a/assets/images/nearlelauncher.png b/assets/images/nearlelauncher.png deleted file mode 100644 index 1bee52b..0000000 Binary files a/assets/images/nearlelauncher.png and /dev/null differ diff --git a/assets/images/nearleplaystore1.png b/assets/images/nearleplaystore1.png deleted file mode 100644 index b21eaa6..0000000 Binary files a/assets/images/nearleplaystore1.png and /dev/null differ diff --git a/assets/images/nearlesplash2.png b/assets/images/nearlesplash2.png deleted file mode 100644 index 8f17e40..0000000 Binary files a/assets/images/nearlesplash2.png and /dev/null differ diff --git a/assets/images/onlinebottom1.png b/assets/images/onlinebottom1.png deleted file mode 100644 index b51a3d5..0000000 Binary files a/assets/images/onlinebottom1.png and /dev/null differ diff --git a/assets/images/onlineoffline.png b/assets/images/onlineoffline.png deleted file mode 100644 index 2b8699d..0000000 Binary files a/assets/images/onlineoffline.png and /dev/null differ diff --git a/assets/images/orderssample.png b/assets/images/orderssample.png deleted file mode 100644 index 2d7d31a..0000000 Binary files a/assets/images/orderssample.png and /dev/null differ diff --git a/assets/images/pending.png b/assets/images/pending.png deleted file mode 100644 index 752f474..0000000 Binary files a/assets/images/pending.png and /dev/null differ diff --git a/assets/images/phone-call .png b/assets/images/phone-call .png deleted file mode 100644 index eb5657a..0000000 Binary files a/assets/images/phone-call .png and /dev/null differ diff --git a/assets/images/profileicon.png b/assets/images/profileicon.png deleted file mode 100644 index c33407d..0000000 Binary files a/assets/images/profileicon.png and /dev/null differ diff --git a/assets/images/selcart.png b/assets/images/selcart.png deleted file mode 100644 index 9b023b3..0000000 Binary files a/assets/images/selcart.png and /dev/null differ diff --git a/assets/images/selecteddelivery.png b/assets/images/selecteddelivery.png deleted file mode 100644 index 9041dc4..0000000 Binary files a/assets/images/selecteddelivery.png and /dev/null differ diff --git a/assets/images/selectedprofile.png b/assets/images/selectedprofile.png deleted file mode 100644 index fe9ebb6..0000000 Binary files a/assets/images/selectedprofile.png and /dev/null differ diff --git a/assets/images/selectedsummary.png b/assets/images/selectedsummary.png deleted file mode 100644 index c1e562b..0000000 Binary files a/assets/images/selectedsummary.png and /dev/null differ diff --git a/assets/images/selecthome.png b/assets/images/selecthome.png deleted file mode 100644 index 4223161..0000000 Binary files a/assets/images/selecthome.png and /dev/null differ diff --git a/assets/images/shoppingbag.png b/assets/images/shoppingbag.png deleted file mode 100644 index 29a659f..0000000 Binary files a/assets/images/shoppingbag.png and /dev/null differ diff --git a/assets/images/signin_banner.png b/assets/images/signin_banner.png deleted file mode 100644 index 9bbd24d..0000000 Binary files a/assets/images/signin_banner.png and /dev/null differ diff --git a/assets/images/splashimg.png b/assets/images/splashimg.png deleted file mode 100644 index 8f17e40..0000000 Binary files a/assets/images/splashimg.png and /dev/null differ diff --git a/assets/images/summary.png b/assets/images/summary.png deleted file mode 100644 index 921d14f..0000000 Binary files a/assets/images/summary.png and /dev/null differ diff --git a/assets/images/today.png b/assets/images/today.png deleted file mode 100644 index 7ecc08c..0000000 Binary files a/assets/images/today.png and /dev/null differ diff --git a/assets/images/total.png b/assets/images/total.png deleted file mode 100644 index 8a3699e..0000000 Binary files a/assets/images/total.png and /dev/null differ diff --git a/assets/images/totalorders.png b/assets/images/totalorders.png deleted file mode 100644 index 860b438..0000000 Binary files a/assets/images/totalorders.png and /dev/null differ diff --git a/assets/images/unslccart.png b/assets/images/unslccart.png deleted file mode 100644 index b1a6ca0..0000000 Binary files a/assets/images/unslccart.png and /dev/null differ diff --git a/assets/images/update.png b/assets/images/update.png deleted file mode 100644 index f4e2b5a..0000000 Binary files a/assets/images/update.png and /dev/null differ diff --git a/assets/images/verify.png b/assets/images/verify.png deleted file mode 100644 index aaab82a..0000000 Binary files a/assets/images/verify.png and /dev/null differ diff --git a/assets/images/week.png b/assets/images/week.png deleted file mode 100644 index 9c5cccb..0000000 Binary files a/assets/images/week.png and /dev/null differ diff --git a/assets/images/white.png b/assets/images/white.png deleted file mode 100644 index 76f6982..0000000 Binary files a/assets/images/white.png and /dev/null differ diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 0000000..a3c193b --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,9 @@ +tags: + # The design snapshots are pictures, not assertions: they are regenerated and + # looked at when the UI changes, and there is no useful "fail" for them — a + # deliberate redesign would break every one. Skipped in a normal run, and + # rendered on demand with: + # + # flutter test test/design_snapshot_test.dart --run-skipped --update-goldens + snapshot: + skip: "render with: flutter test test/design_snapshot_test.dart --run-skipped --update-goldens" diff --git a/design/DESIGN.md b/design/DESIGN.md new file mode 100644 index 0000000..bad564c --- /dev/null +++ b/design/DESIGN.md @@ -0,0 +1,471 @@ +# Doormile Customer App — Design Documentation + +How every screen is designed, why it is designed that way, and where each +decision lives in the code. + +Companion to `README.md` (which covers running and shipping the app). + +--- + +## 1. Principles + +Five rules decide almost every argument in this app. + +1. **One decision per screen.** A screen asks one thing and offers one dominant + action. Everything else is secondary or absent. +2. **Lift, don't outline.** Surfaces separate through soft shadow on a warm + canvas, not through borders. Borders appear only where an outline is + genuinely load-bearing. +3. **Optional must look optional.** Anything the customer can skip is labelled + *Optional* and, when skipped, described calmly — never with warning styling. +4. **Say it in customer language.** No consignment, waybill, AWB, MPS or piece. + The customer books a pickup and sends packages. +5. **Complexity appears only when asked for.** Multi-destination, multi-package + and every advanced field stay folded away until someone reaches for them. + +--- + +## 2. Foundations + +All of this lives in `lib/ui/tokens.dart`. No screen hardcodes a value. + +### 2.1 Colour + +One accent. Warm neutrals. Semantic colour used sparingly and only with meaning. + +| Token | Hex | Used for | +| --- | --- | --- | +| `brand` | `#960019` | Primary actions, selection, live status, links | +| `brandPress` | `#7A0015` | Pressed state of a primary surface | +| `brandSoft` | `#FDF2F4` | Selected tiles, tinted glyph circles, brand chips | +| `brandLine` | `#F3D9DF` | Pressed state of a soft brand surface | +| `ink` | `#14100F` | Primary text — a warm near-black, never `#000` | +| `ink2` | `#5A5350` | Secondary text, lede paragraphs | +| `ink3` | `#8B8480` | Tertiary text, eyebrows, captions | +| `ink4` | `#B5AEAA` | Placeholders, disabled labels, chevrons | +| `canvas` | `#F7F5F3` | Page background — warm off-white | +| `surface` | `#FFFFFF` | Cards, sheets, the tab bar | +| `surfaceAlt` | `#F2EFEC` | Filled inputs, quiet chips, skeleton base | +| `hairline` | `#EFEBE8` | Dividers inside a shared surface | +| `border` | `#E3DEDA` | The rare real outline | +| `ok` / `okSoft` | `#0E6B46` / `#E9F4EF` | Delivered, money settled, confirmations | +| `danger` | `#B3231F` | Cancel and other destructive labels | + +**Rules.** Green means completed or paid — never decoration. Red is either the +brand or destruction, and context always disambiguates. Disabled states desaturate +rather than grey out to a different hue. + +### 2.2 Typography + +**Inter**, bundled as a real asset in four weights (400/500/600/700) so the app +renders identically on every device. Headings carry tight negative tracking; +body text stays open. + +| Style | Size / Height / Weight | Tracking | Used for | +| --- | --- | --- | --- | +| `display` | 32 / 1.14 / 700 | −1.1 | The one dominant line on a screen (tracking status, "Pickup booked") | +| `pageTitle` | 28 / 1.18 / 700 | −0.9 | Tab-root titles (Orders, Account) and the Home question | +| `title` | 26 / 1.2 / 700 | −0.8 | Step headings inside the booking flow | +| `heading` | 21 / 1.25 / 600 | −0.5 | Card headings, amounts | +| `sheetTitle` | 21 / 1.25 / 700 | −0.6 | Bottom-sheet titles | +| `cardTitle` | 17 / 1.3 / 600 | −0.35 | Card and row titles | +| `body` | 16 / 1.5 / 400 | −0.1 | Input text, long copy | +| `bodyStrong` | 16 / 1.4 / 600 | −0.25 | Emphasised row titles | +| `lede` | 15 / 1.5 / 400 | −0.1 | The supporting line under a heading | +| `value` | 15 / 1.4 / 500 | −0.15 | Data values in summary rows | +| `label` | 13 / 1.3 / 600 | −0.05 | Buttons-in-text, chips, inline actions | +| `small` | 13 / 1.45 / 400 | −0.05 | Captions, secondary metadata | +| `eyebrow` | 11 / 1.3 / 600 | **+0.85**, uppercase | Micro-labels: `PICKUP`, `SENDING TO`, `STEP 2 OF 4` | +| `sectionHead` | 15 / 1.3 / 600 | −0.2 | Sentence-case section headings ("Recent orders") | + +Two label systems on purpose: **uppercase eyebrows** name a *field*, **sentence-case +section heads** name a *group*. Mixing them was the fastest way to make the first +version look like a wireframe. + +### 2.3 Shape + +`xs 10 · sm 14 · md 18 · lg 22 · xl 28 · pill 999` + +Tightest on inputs (14), mid on buttons and tiles (18), wider on cards (22), +widest on sheets and the hero CTA (28). Radius increases with the size of the +surface, which keeps corner curvature visually constant. + +### 2.4 Elevation + +Four shadows, each with one job. + +- **`soft`** — resting elevation for every card. Two layers: a 2px contact + shadow plus a 24px ambient one at −12 spread. The contact layer is what stops + cards floating. +- **`lift`** — brand-tinted, for the single hero CTA on Home. +- **`float`** — controls hovering over content (map badges, toast, locate button). +- **`sheet`** — upward shadow under a bottom sheet. + +### 2.5 Spacing + +- Page padding **20**, used everywhere so every screen shares a left edge. +- Minimum touch target **48**; primary button height **54**. +- Vertical rhythm in 4s: 4 / 8 / 12 / 16 / 20 / 26 / 28. +- Section gap 26–28, intra-card gap 12–16, label-to-field 8. + +### 2.6 Motion + +`fast 140ms · base 220ms · slow 340ms`, all on `easeOutCubic`. + +Press feedback is a scale to 0.98–0.985 at `fast`. Selection and expansion run +at `base`. Progress rails and route drawing run at `slow`. Continuous animations +(radar, live dot, map pulse, skeleton shimmer) run on their own loops and are +the reason tests pump fixed slices instead of settling. + +--- + +## 3. Component library + +`lib/ui/widgets/` — screens are thin because these carry the design. + +| Component | File | Role | +| --- | --- | --- | +| `DmButton` | `buttons.dart` | primary / soft / ghost / outline, busy state, optional trailing icon | +| `DmChipButton`, `DmTextAction`, `DmIconButton` | `buttons.dart` | secondary affordances | +| `DmTopBar`, `DmStepHeader`, `DmFooter`, `DmPageTitle`, `DmScreenHeader` | `chrome.dart` | screen scaffolding | +| `DmOptionTile`, `DmActionRow` | `option_tile.dart` | selectable list card, tappable action row | +| `DmTextField`, `DmSegmented`, `DmRow`, `DmRowGroup`, `DmSwitch` | `inputs.dart` | form controls | +| `DmSummaryCard`, `DmSummaryRow`, `DmNote`, `DmConfirmedStrip` | `summary.dart` | fact display | +| `DmAsyncList`, `DmSkeleton`, `DmEmptyState` | `states.dart` | every remote-data state in one wrapper | +| `DmPill`, `DmLiveDot`, `DmAvatar`, `DoormileMark` | `misc.dart` | status and identity | +| `DmTag`, `DmChoiceChip`, `DmGridTile`, `DmStepper`, `DmInfoBanner`, `DmCopyRow` | `pieces.dart` | small primitives | +| `DmRouteMap`, `DmRadarPulse` | `route_map.dart` | painted map, matching animation | +| `DmRouteRail` | `route_rail.dart` | pickup → drop, vertical | +| `DmMilestones` | `milestones.dart` | the journey checklist | +| `DmToast`, `showDmSheet`, `DmSheetHeader` | `feedback.dart` | transient and modal | + +Four of these do disproportionate work: + +**`DmAsyncList`** wraps every backend-driven list. Loading skeleton, empty state, +error-with-retry and the success builder all live in one place, so a new endpoint +inherits four designed states for free. It is why the empty and error screens +look intentional rather than bolted on. + +**`DmRouteRail`** — an origin ring, a hairline connector, a brand pin. The same +component anchors an order card, the tracking facts and the receipt, so a journey +reads identically wherever it appears. + +**`DmRouteMap`** — a `CustomPainter`: rotated city blocks, white roads, a cubic +route stroked with a white casing then brand fill, an origin ring, a destination +pin with a blurred drop shadow, and a pulsing live marker positioned along the +path via `PathMetrics`. No map SDK, no API key. Swap it for a real map widget and +nothing around it changes. + +**`DmOptionTile`** — white card at rest with `soft` shadow; selected becomes +`brandSoft` with a 1.5px brand border and a filled check; disabled drops to +`surfaceAlt` and shows the backend's reason. One component covers choose, +chosen, and cannot-choose. + +--- + +## 4. Screens + +### 4.1 Auth — Login, Sign up, OTP + +`lib/ui/screens/auth/` + +**Design.** A maroon brand panel at the top, a white sheet rounded 30px over it +holding the form. This is the only full-brand moment in the app: it establishes +the identity once, then the product stays quiet. `AuthScaffold` owns the panel, +the sheet, the pinned footer and the legal line, so all three screens are +identical in structure and differ only in fields. + +- **Login** — Phone/Email segmented, `+91` prefix field with a clear button, a + CTA that only enables on a valid input, and *New to Doormile? Create account*. +- **Sign up** — full name, phone, optional email. Validation is the same shape: + the CTA enables, it never scolds. +- **OTP** — its own screen, not an inline step, because it has its own back + semantics. The number is shown with a *Change* affordance, four large boxes + auto-advance, a pasted code spreads itself across them, a resend countdown + ticks from 0:30, and verification fires automatically on the fourth digit. + +**Why a brand panel here and nowhere else:** auth is the one screen with no +content of its own, so it is the only place a large colour field costs nothing. + +### 4.2 Shell and navigation + +`shell_screen.dart` — Home · Orders · Account in an `IndexedStack`. + +The bar **floats**: a white surface inset 16 from the edges, radius 28, lifted +by a two-layer shadow, so it sits on the canvas like every other surface rather +than sealing the bottom of the screen. The active tab is a `brandSoft` pill that +**slides** between destinations on `easeOutBack`, its icon switching from +outline to filled with a small upward nudge and its label easing to `w600` in +brand. Inactive icons and labels stay `ink3`. + +Booking and tracking screens are **pushed over** the shell, so the tab bar never +appears where it would compete with a step flow. + +### 4.3 Home + +`home_screen.dart` + +Three things, in priority order: + +1. **Greeting + question** — "Good afternoon, Joe" in `lede`, then "Where should + we pick up?" in `pageTitle`. The screen asks the question the CTA answers. +2. **Book a Pickup** — the only maroon block in the app, radius 28, `lift` + shadow, title + one supporting line + an arrow in a translucent circle. +3. **What's happening** — an *Active* card (live dot, window tag, stage, who is + coming, Track →) and recent orders as route cards. + +Pull-to-refresh re-detects location and refreshes orders. The pickup-address row +was removed once the pin moved into booking step 1 — Home should not carry a +setting. + +### 4.4 Booking + +Four steps, a shared `DmStepHeader` (`STEP n OF 4` on the left, the step name on +the right, an inset rounded progress rail beneath). + +**Step 1 — Pickup point** (`pickup_location_screen.dart`) +Map panel with a pulsing pin and a locate button, the detected address in a card +with *Change* (opens the place-search sheet), and **Next**. Location is detected +first; correcting it is one tap; nothing is typed unless the customer wants to. + +**Step 2 — Destination** (`destination_screen.dart`) +The screen that carries the multi-destination model without exposing it. + +- Opens on a **state chip row** (horizontal, scrollable). Only states we can + actually serve appear — a state with nothing open is never offered. Choosing + one reveals a **two-column district grid** of serviceable districts only. + Unsupported places are never disabled tiles; they are named once, quietly: + *"Coming soon: Madurai"*. The delivery promise moves to the settled card, + where it informs a decision instead of decorating a picker. +- Choosing a district **collapses the block into a card**: the place, *How many + packages?* with a `− 1 +` stepper defaulting to 1, and *Add exact delivery + details · Optional*. +- Below it, a quiet **+ Send to another place**. That is the entire + multi-destination affordance — one text action, absent until the first place + is settled. +- Returning from Review shows settled cards, not the picker (`initState` opens + the first incomplete block, or none). + +Caps come from `BookingLimits` (backend-served, 20 packages / 5 destinations). +When a cap is reached the action is replaced by a plain sentence — *"Up to 5 +places in one pickup. Book another pickup for more."* — never an error. + +**Step 3 — Pickup time** (`slot_screen.dart`) +The pickup address restated in a compact card with *Change*, then windows +grouped by day. Each slot card can carry badges (*Fastest pickup*, *4 Milers +nearby*) and a caption; full windows are shown disabled with their reason. The +slot belongs to the whole pickup — one visit collects everything. + +**Step 4 — Review** (`review_screen.dart`) +Compact by design and identical in shape for one destination or five. + +- Route map with a badge that adapts: *Direct door-to-door transit* or *One + pickup · 3 destinations*. +- Summary card: one **connected rail** — the pickup origin, then every + destination hanging off it with its package count, exactly as an order card + reads. `DmRouteRail` takes a list of stops for this. *Edit* sits on the + pickup row, *Change destinations* under the stops, and **Pickup time** is the + row beneath. +- Optional omissions are stated once, calmly: *"Not added — the Miler can + confirm this at pickup."* With several places it becomes a single line rather + than repeating. +- For multi: `6 packages · 3 destinations` · *One Miler visit*. +- Combined estimate with the honest caveat that the final amount settles after + the Miler verifies. +- One dominant CTA: **Book Pickup**. + +**Confirmation** (`confirmed_screen.dart`) +A green check that pops in on `easeOutBack`, "Pickup booked", the package +summary as a brand tag, a map with a *Searching for the nearest Miler* badge, +the reference with a copy action, the scheduled window and destinations, and two +quiet informational banners. Then **Track pickup** / **Back to home**. + +### 4.5 Tracking + +`tracking_screen.dart` — one screen that changes shape by stage. + +- **Status header.** Eyebrow (`PICKUP STATUS` / `ORDER STATUS`) with a status + pill, then the dominant line — personalised where it helps: *"Arun Kumar is on + the way"*. Below it one or two fact chips (ETA, window). +- **Hero.** While a Miler is being matched: a **radar pulse** with three + expanding rings. Once assigned: the **route map** with the live marker, badged + *En route · 1.4 km away*. +- **Miler card.** Avatar, name, rating and completed pickups, vehicle and type, + then **Call Miler** (soft) and **Message** (outline). +- **Milestones.** Seven customer stages, not nine backend ones: *Pickup booked + → Miler assigned → Pickup in progress → Package collected → In transit → Out + for delivery → Delivered*. Operational detail — "Miler on the way", "Miler + arrived at your door", "Order created" — appears as one line of context under + the milestone in progress, never as another permanent row. Completed + milestones carry a filled check and a timestamp; the current one a ringed + marker and a **LIVE** tag. + + Status pills everywhere use the same customer language + (`JourneyStage.milestoneLabel`), so a pill never reads "Order created". +- **Your packages.** Appears **only** after `orderCreated` and only when the + pickup carried several destinations — each with its own tracking ID, package + count and status pill. Before collection the customer has one scheduled visit, + not several shipments, and the UI says exactly that. +- **Facts.** Route rail plus pickup window and reference/tracking number. +- **Footer.** *Cancel pickup booking* while backend policy allows it, then + *Need help with this order?*, then *Book another pickup* once finished. + +### 4.6 Orders + +`orders_screen.dart`, `order_row.dart` + +Page title with a live subtitle ("3 bookings in progress"), an Active / +Completed / Cancelled segmented control, and **route cards**: a compact +`DmRouteRail`, a hairline, then the identifier and its facts on the left and a +status pill on the right. + +**Ownership changes at collection.** Before the Miler arrives, a booking is one +row — *"Pickup DM-482913 · 3 packages · 2 destinations"* — because the customer +has one scheduled visit. Once verification creates the orders, each destination +stands on its own row with its own tracking number and journey, carrying a quiet +line back to the visit they shared: *"Collected together · Pickup DM-471200"*. +`AppState.entriesOf` produces these rows; the card renders either shape. + +Opening a destination row follows **that order** — tracking takes a +`focusedDestination` and shows its own status, milestones and rail, with the +sibling packages listed beneath. Live rows open Tracking; finished ones open the +receipt. + +### 4.7 Order details (receipt) + +`order_details_screen.dart` + +For a finished order: status and delivered date, the full route rail, then +**Collected at your door** — what the Miler actually recorded at pickup: +the photographs they took (one per package, captioned "1 of 3"), the **weighed +weight**, and who weighed it and when, tagged *Miler verified*. This is the +customer's evidence of what was handed over, and the weight the final price was +calculated from. + +Then a **Packages** breakdown when there was more than one destination, and a +**Payment** block itemising parcel, base fare, distance, weight adjustment, +verified weight, **total paid** and method. Then the facts (reference, per-destination tracking, +window, recipient, collected by) and the journey milestones. + +This screen answers "what did it cost and when did it land" without making +anyone hunt. + +### 4.8 Account + +`account_screen.dart` — a profile card, then grouped rows: preferences, then +**Prototype controls** (simulate empty service areas / network error / slow +network, reset data). Sign out is a ghost danger button, and the build version +sits quietly beneath. + +The prototype controls are deliberately in the product rather than hidden: they +are how anyone reviewing the app reaches the states that are otherwise hard to +produce. + +### 4.9 Sheets + +`showDmSheet` gives every sheet the same grabber, 28px top radius and padding. + +- **Place search** — search field, debounced results, skeletons while loading. +- **Delivery details** — the optional address and recipient fields for one + destination. *Save details* / *Skip for now*. +- **Cancel booking** — the reference and window restated, optional reason chips, + then *Keep my pickup* (primary) and *Yes, cancel pickup* (ghost, danger). The + safe choice is the prominent one. + +--- + +## 5. State design + +Every remote list has four designed states, provided by `DmAsyncList`: + +| State | Treatment | +| --- | --- | +| Loading | Shimmering skeleton rows at the real row height, so nothing jumps | +| Empty | 64px glyph circle, heading, calm explanation, optional soft action | +| Error | Wi-Fi-off glyph, *"Couldn't load this"*, the server's message, **Retry** | +| Unavailable item | Rendered, dimmed, with the backend's reason attached | + +Disabled controls desaturate and stay in place. A capped action is replaced by an +explanatory sentence, never an alert. + +--- + +## 6. Interaction and haptics + +- Selection (option tile, chip, grid tile, stepper, segmented, tab) → + `HapticFeedback.selectionClick()`. +- Booking confirmed and booking cancelled → `HapticFeedback.mediumImpact()`. +- Page transitions fade and slide 6% from the right on `easeOutCubic`. +- Pull-to-refresh on Home and Orders. +- Edge-to-edge, portrait-locked, dark status-bar icons — light over the auth + brand panel. + +--- + +## 7. Accessibility and resilience + +- **No text-scale clamp.** Components adapt instead: rows grow vertically, + buttons use a minimum height rather than a fixed one, the district grid drops + to one column past 1.15×, and important text wraps. Ellipsis is reserved for + secondary information — a delivery promise may truncate, a place name never + does. Two widget tests drive the whole booking flow, Orders and the receipt at + **1.8× text scale**; any overflow fails them. +- Every interactive surface carries `Semantics` with button/selected/enabled. +- Minimum 48px targets throughout. +- Every text row that can meet long data is `Flexible` with `ellipsis` — the + widget tests run in a font where every glyph is full-width, which is how the + remaining overflows were found and fixed. +- Contrast: `ink` on `surface` and white on `brand` both clear AA. + +--- + +## 8. Voice + +- Customer language only: *pickup*, *packages*, *destination*, *Miler*. +- Never: consignment, waybill, AWB, MPS, piece, manifest. +- Optional things are described, not warned about. +- Numbers are stated plainly — *"6 packages · 3 destinations"*, *"₹64 paid"*. +- Buttons are verbs: *Book Pickup*, *Track pickup*, *Cancel pickup booking*. + +--- + +## 9. How design maps to code + +``` +lib/ui/tokens.dart every colour, type style, radius, shadow, duration +lib/ui/widgets/ the component library — screens compose, never restyle +lib/ui/screens/ layout and copy only +lib/data/doormile_api.dart the only place mock data lives +lib/state/app_state.dart one source of truth; all stage side effects +``` + +Rules that keep it honest: a screen never hardcodes a colour or size; business +rules (serviceability, caps, slot availability, pricing) never live in a widget; +and anything the backend will own is already served through the API layer so the +swap is a body change, not a redesign. + +--- + +## 10. What is real and what is mocked + +**Real:** the whole UI, navigation, state machine, validation, caps enforcement, +optionality rules, and every loading/empty/error path. + +**Mocked in `doormile_api.dart`:** serviceable states and districts, pickup +slots, geocoding and place search, fare estimates, booking creation and +cancellation, and booking limits. Each returns the shape a real endpoint would. + +**Stand-ins:** `DmRouteMap` and `DmMapPanel` are painted, not a map SDK. Pickup +photos render as honest placeholders — `ParcelVerification.photos` holds the +references, so `Image.network(...)` drops straight in. The *Simulate update* +stepper on Tracking replaces backend push events — deleting that one widget is +the only change needed when real status events arrive. + +**Not built yet:** session persistence, push notifications, in-app support chat, +per-destination cancellation (cancellation is whole-pickup, before collection +only). + +--- + +*This document is the CX UI baseline. The visual system, screens and booking +structure are approved and settled.* diff --git a/docs/API_READINESS.md b/docs/API_READINESS.md new file mode 100644 index 0000000..bdc9391 --- /dev/null +++ b/docs/API_READINESS.md @@ -0,0 +1,187 @@ +# API-readiness audit — `doormile_cx` + +Every mocked call still to be replaced, what it must send, what it must return, +and which screen breaks if it is wrong. + +**Rules that hold across all of them** + +* All 19 live in `lib/data/doormile_api.dart`. Replace the method body, keep the + return type, and no screen changes. +* No widget calls `DoormileApi` directly any more — screens go through + `AppState`, which holds the injected instance. The UI cannot tell mock from + real, which is the property that makes this a body-swap rather than a rewrite. +* Every failure must throw `ApiException(code, message)` where `message` is + customer-safe: `DmAsyncList` renders it verbatim with a Retry. +* `LocationService` (device GPS) and `DmMapConfig` (tile provider) are **not** + on this list — they are platform and build configuration, not backend. + +Status legend — **Blocking**: the flow does not work without it · +**Wired**: seam exists and is called, mock returns a no-op · +**Delete**: prototype-only, remove at go-live. + +--- + +## Auth + +| # | Method | Sends | Must return | Consumed by | Status | +|---|---|---|---|---|---| +| 1 | `sendOtp(identifier)` | `identifier` — E.164 phone (`+919876543210`) or email | nothing; **please add** `resendAfterSeconds`, `codeLength` so the 30 s timer is server-driven | `login_screen`, `otp_screen` (Resend) | Blocking | +| 2 | `signUp({name, phone, email?})` | `name` ≥ 2 chars, `phone` E.164, `email` optional | nothing — creates the account *and* sends the code | `signup_screen` | Blocking | +| 3 | `verifyOtp(identifier, code, {name})` | `identifier`, 4-digit `code`, `name` only on signup | `Customer{id, name, phone, email}` — `email` may be `""` but **never null**. **Please add** `accessToken`, `refreshToken`, `expiresIn` | `otp_screen` → `AppState.verifyOtp` | Blocking | + +Not yet seamed: token refresh, `GET /me`, logout. Session persistence is +unbuilt and waits on these. + +## Catalogue + +| # | Method | Sends | Must return | Consumed by | Status | +|---|---|---|---|---|---| +| 4 | `getServiceableStates()` | — | `List` · `code`, `name`, `districtCount` (**available districts only** — the app hides a state at 0), `transitTag?` ≤ 22 chars | Step 2 state picker (`destination_screen`) | Blocking | +| 5 | `getServiceableDistricts(stateCode)` | `stateCode` | `List` · `code`, `name`, `available`, `note?`, `hub?`, `promise?`, **`lat`/`lng`** (hub position — the route map draws to it). **Return unavailable districts too**; the client filters them but uses the names for "Coming soon" | Step 2 district picker | Blocking | +| 6 | `getPickupSlots({pickup})` | pickup `lat`/`lng` | `List` · `id`, `day`, `window`, `available`, `note?`, `tag?`, `milersNearby`, `caption?`. `day`/`window` are **server-formatted IST display strings** | Step 3 (`slot_screen`) | Blocking | +| 7 | `getBookingLimits()` | — | `maxPackages`, `maxDestinations` | Step 2 caps, add-destination button | Blocking (safe defaults 20/5 on failure) | + +## Location + +| # | Method | Sends | Must return | Consumed by | Status | +|---|---|---|---|---|---| +| 8 | `reverseGeocode(lat:, lng:)` | `lat`, `lng` under the pin | `Place{title, sub, lat, lng}` — `title` primary line ≤ 32 chars, `sub` supporting street/city. Both required | Pickup card address box | Blocking · **hottest call in the flow** — fires on every map settle. p95 < 300 ms, cache by rounded coordinate | +| 9 | `searchPlaces(query)` | `query` (empty ⇒ recent/saved, ≤ 4), bias to `lat`/`lng` | `List` — **`lat`/`lng` are mandatory**; a result without them cannot become a pin | Place search sheet | Blocking | + +## Booking + +| # | Method | Sends | Must return | Consumed by | Status | +|---|---|---|---|---|---| +| 10 | `estimateFare({pickup, destinations})` | pickup `lat`/`lng`; per destination `stateCode`, `districtCode`, `packageCount` | `FareEstimate{min, max, paymentMethod, parcel}` — ints in ₹, combined for the whole visit | Review total, receipt lines | Blocking (a failure must not block booking) | +| 11 | `createBooking({pickup, destinations, slotId, fare})` | `pickup` (title, sub, **lat, lng**), `slotId`, `destinations[]` (`stateCode`, `districtCode`, `packageCount`, optional `details`), shown `estimate` | `Booking` with `reference` (`DM-######`), `stage: booked`, `status: active`, `cancellable: true`, one-entry `history`, **no `trackingId` anywhere** | Review → Confirmed | Blocking · needs `Idempotency-Key` | +| 12 | `cancelBooking(reference, reason)` | `reference`, `reason?` (5 presets or free text, may be null) | nothing; `409` once past `arrived` | Cancel sheet, Tracking footer | Blocking | +| 13 | `isCancellable(stage)` | — (pure policy mirror) | `true` through `arrived` | Tracking footer | Blocking · server re-checks regardless | + +## Reading back + +| # | Method | Sends | Must return | Consumed by | Status | +|---|---|---|---|---|---| +| 14 | `getBookings({status})` | `status?`, `limit`, `cursor` | `List?` — **null means "keep the local copy"**, which is all the mock does. Return rows and `AppState` adopts them wholesale | Home "Recent", Orders tabs, pull-to-refresh | Wired | +| 15 | `getBooking(reference)` | `reference` | `Booking?` — same null contract. Full object per §9.3 of the requirements doc | Tracking (on open), Order details | Wired · **this is what retires the debug stage stepper** | +| 16 | `slotById(id)` | — (sync) | `PickupSlot` for a booking's window | Review, Confirmed, Tracking, Order details, Home card, Cancel sheet | ⚠️ **Design gap** — served from whatever `getPickupSlots` last returned. A booking made yesterday cannot name its window. Fix by putting `slot{day, window}` **on the booking object**, then this method disappears | +| 17 | `districtByCode(code)` | — (sync) | `District` with `lat`/`lng` for the route map | Route map on Review, Confirmed, Tracking | Falls back to a built-in centroid table until #5 sends coordinates | + +## Delete at go-live + +| # | Item | Why | +|---|---|---| +| 18 | `newReference()`, `newTrackingId()` | Ids are minted by the backend — `DM-######` at booking, `DMX########` per destination at `order_created` | +| 19 | `ApiFlags` + the Account ▸ Prototype controls | Empty / error / slow simulation. Replace with real staging error injection | +| — | `AppState.seedDemoOrders()`, `_applyStage` side effects | Every side effect in `_applyStage` (assigning a Miler, minting tracking ids, settling `amountPaid`, capturing weight and photos) is something the **backend** performs. It exists so the prototype can show each stage; it must not survive real status events | +| — | `_StageStepper` on the tracking screen | The stand-in for push updates. Deleting it is the only client change once #15 and push land | + +## Not seamed at all yet + +These have no mock method to replace — they need new client work alongside the +backend, and are listed so nobody assumes they are covered: + +* **Push registration** (`POST /devices`) and notification handling — no seam. +* **Deep links** — `doormile://track/…` has no intent filter on Android and no + URL types on iOS. +* **Session persistence** — no token storage; depends on auth #3. +* **Delivery details write-back** (`PATCH …/destinations/{i}`) — the sheet edits + the draft in memory; there is no method to push a post-booking edit. +* **Parcel photos** — `ParcelVerification.photos` are rendered as placeholder + tiles; they need fetchable signed URLs before the receipt shows real images. + +--- + +## Contract conformance pass — 8 Sep 2026 + +Audited against the customer API document (28 routes, `/customer/*`). The seams +were all in place; what was wrong was the **wire shape** inside them, which is +the class of mistake that costs nothing at compile time and a whole feature at +runtime. Fixed here: + +| What | Was | Now | +|---|---|---| +| `POST /auth/otp/verify` | sent `code` | sends `otp`. Nothing verified before this | +| `POST /fare/estimate` | sent `lat`/`lng` + `packageCount`; read `min`/`max` | sends `latitude`/`longitude` + `packages[]`; reads `minRupees`/`maxRupees`/`routeKm`. No price could ever have been shown | +| `POST /bookings` | nested `details{}`, `lat`/`lng`, no contact, no `remarks`, extra `estimate` | flat destinations, `latitude`/`longitude`, `contactName`/`contactPhone` from the session, per-destination notes joined into `remarks` | +| Booking response | `destination.label` gated on `stateCode`/`districtCode`, which the response does not send | reads the **names**. Every read-back destination rendered as `—` before | +| `pickup.latitude` | read only `lat` | reads either. The tracking map had no pin | +| `error.code` | compared capitals to lowercase constants | translated by `ApiException.normalise`. `UNAUTHORIZED` did not satisfy `isAuthFailure`, so **the token refresh never fired** and every session died at one hour | +| `isCancellable` | `<= arrived` | `< arrived`. `arrived` is the contract's cutoff; offering Cancel there invites a `BOOKING_NOT_CANCELLABLE` the app then has to explain | +| List envelopes | `data` had to be a bare array | also read through `data: { items: [...] }`, cursor and total from either level | +| `maxCodAmount`, `codAmount`, destination `index` | dropped on the floor | parsed and carried. No screen sets a COD amount yet — see below | +| `POST /ops/bookings/{ref}/stage` | not wired at all | `DoormileApi.setStage`, driving the tracking stepper on a staging build (`--dart-define=DM_ALLOW_STAGE_OVERRIDE=true`) | +| `GET /pickup-slots` | no revalidation | `ETag`, keyed per zone | +| `GET /places/search` | sent `q` only | biases by the pickup coordinates | + +### Open with the backend + +1. **`packages[].weightKg` on the fare estimate.** This app never asks the + customer what a parcel weighs — the Miler weighs it at the door, which is + when the price settles — so it sends one unweighted entry per package. If + the field is mandatory, the estimate needs a documented default rather than + a number the client invents. +2. **The saved-locations body** (`POST`/`PUT /customer/locations`) is not in the + document. It is sent as `{title, sub, latitude, longitude, label}`, matching + the coordinate spelling used everywhere the contract *is* explicit. +3. **List envelopes.** Whether a paged route answers with a bare `data` array or + `data: { items, nextCursor }` is not specified; the client reads both. +4. **COD.** `codAmount` is parsed and echoed but no screen offers to set one, and + `maxCodAmount` defaults to `0` = not offered. Needs a product decision before + it is more than a field. + +### Still not wired, and not a contract problem + +* `GET /orders/{trackingId}` — implemented, but nothing calls it: there are no + deep links (no Android intent filter, no iOS URL types). +* `POST`/`DELETE /devices` — implemented, but no push SDK produces a token. +* `PATCH /bookings/{ref}/destinations/{i}` — implemented through + `AppState.saveDestinationDetails`, but the details sheet only edits the + **draft**. There is no post-booking edit screen to call it from. + +--- + +## Mock removal — 8 Sep 2026 + +The "Delete at go-live" list above is done, and then some. `lib/` now contains +**one** API implementation. + +| Removed from `lib/` | Where it went | +|---|---| +| `data/mock_doormile_api.dart` | `test/support/fake_doormile_api.dart`, injected via `DoormileApi.overrideInstance`. Not compiled into the app | +| `AppConfig.useMock` / `DM_MOCK` / `isTest` / `showPrototypeControls` | gone — there is no define that serves invented data | +| `AppConfig.skipAuth` / `DM_SKIP_AUTH` and its "Dev Build" customer | gone — the app is signed into by signing in | +| `ApiFlags` and Account ▸ Prototype controls | `FakeFlags` on the test double | +| `DoormileApi.isMock`, `newReference()`, `newTrackingId()` | gone — the server owns both identifiers | +| `AppState.seedDemoOrders()` / `resetDemo()` / `advanceTo()` / `_applyStage()` | the fake's own `_seed()` and `applyStage()`. **The app can no longer move a booking's stage at all** | +| `AppState.setFlag()` / `flag()` | gone | +| `ui/screens/dev_entry.dart` (`DM_START`) | deleted — it fabricated a signed-in customer and a pickup point | +| `getBookings()` | gone — dead since `refreshOrders` moved to `getBookingPage` | + +`AppState.refreshOrders` now adopts every page it is given, empty ones included. +The client keeps no booking the backend has not sent. + +### Invented defaults, also removed + +These were the quieter half: fields that read as facts on screen but were +client-side constants when the backend said nothing. + +| Was | Now | +|---|---| +| `Person.rating = 4.9`, `trips = 1240`, `vehicleType = 'E-Scooter'` | nullable. The "4.9 · 1240+ pickups" line and the vehicle type are **absent** unless the backend sent them — a default put an invented reputation on a real person's card | +| `Booking.routeKm = 6.4` → "6.4 km" on the receipt | nullable; the Distance row is dropped | +| `Booking.milersInZone = 3` → "3 verified Milers active in your zone" | defaults to 0 and the banner is not shown | +| `fare.parcel ?? 'Standard box'`, `fare.paymentMethod ?? 'UPI'` | nullable; both rows dropped | +| Account's `customer?.name ?? 'Joe Oommen'` | empty | +| Parcel photos drawn as a placeholder tile | `Image.network` when the reference is a fetchable URL, falling back to the frame | +| "not part of this prototype" | "not available yet" | + +### What this does not fix + +A booking created here reaches the backend and the admin console's booking list. +Its **badge** still stalls at `picked`, because the console derives it from +`mapBookingStatusToDeliveryStatus(booking.status)` and booking status ends at +`Converted_To_Consignment` — the delivery half of the lifecycle lives on the +consignment, exactly as §4 of the customer API document marks `in_transit`, +`out_for_delivery` and `delivered` as *Per Destination*. That is a console-side +join (`getConsignments()` by `booking.consignmentid`) and cannot be fixed from +this app. diff --git a/docs/BACKEND_REQUIREMENTS.md b/docs/BACKEND_REQUIREMENTS.md new file mode 100644 index 0000000..52566da --- /dev/null +++ b/docs/BACKEND_REQUIREMENTS.md @@ -0,0 +1,782 @@ +# Doormile — Backend Requirements (Customer App v1) + +**Audience:** backend team +**Client:** `doormile_cx` — Flutter customer app, Android/iOS, app id `in.doormile.customer` +**Client status:** UI complete, `flutter analyze` clean, 20 widget tests green. It runs +entirely against an in-app mock (`lib/data/doormile_api.dart`) — every value on every +screen comes from that one file. This document specifies the service that replaces it. +**Match these shapes and no screen changes are required.** + +**Read §2 first.** A production Doormile backend already exists and the Miler app is +live against it. The customer app is a *new namespace on that same service*, not a new +system, and it must read the records the Miler app writes. Anything that treats the +customer API as greenfield will produce two databases and a reconciliation problem. + +--- + +## 1. The product model you are implementing + +This is not the usual courier model. Read this before the endpoints. + +> **The customer books a pickup, not a shipment.** + +``` +Pickup booking (reference: DM-482913) + ├── destination 1 : Chennai, Tamil Nadu · 2 packages + ├── destination 2 : Ernakulam, Kerala · 1 package + └── destination 3 : Bengaluru Urban, KA · 1 package + │ + the Miler collects everything in ONE visit + │ + ▼ + Orders are created HERE — one per destination + order 1: DMX10482913 order 2: DMX10559120 order 3: DMX10662004 + each with its own tracking number and its own journey +``` + +Consequences you must honour: + +1. **No tracking number exists at booking time.** `POST /customer/bookings` returns a + `reference` only. Tracking numbers are minted **per destination** when the Miler + completes pickup (stage `order_created`). +2. **One booking → 1..N destinations → 1..N orders.** A single-destination booking is + the common case and must not be a special case in the schema. +3. **Weight is never collected from the customer.** The Miler weighs and photographs + each package at the door — that is also when the **price settles**. Everything the + customer sees before then is an estimate range. +4. **Only state + district are required per destination.** Street, building, landmark, + recipient name/phone and instructions are optional at booking time and may be + completed by the Miler at pickup. The UI shows missing ones as *"Not added — the + Miler can confirm this at pickup."* +5. **Doormile is the carrier, not the seller.** The goods belong to the customer or to + the customer's own buyer. Any money at the door is the customer's, collected on + their behalf. Never write copy or schema that treats Doormile as the merchant. +6. **Cancellation is whole-pickup only**, and only up to and including `arrived`. + +### Glossary + +| Term | Meaning | +| --- | --- | +| **Miler** | The agent who collects from the customer's door. Also used for the delivery agent. | +| **Pickup booking** | What the customer creates. Identified by `reference`, format `DM-######`. | +| **Destination (group)** | One place inside a booking + how many packages go there. | +| **Order** | Created per destination at pickup completion. Identified by `trackingId`, format `DMX########`. | +| **Consignment** | The existing backend's name for what the customer calls an order (delivery leg). | +| **Stage** | Operational status, 9 values (§8). | +| **Milestone** | The rolled-up status the customer sees, 7 values (§8). | + +--- + +## 2. Where this fits: the backend that already exists + +The Miler (rider) app runs against **`https://api.doormile.com/api/v1`** with a bearer +JWT, and uses ~40 endpoints, all under `/miler/*`: + +``` +/miler/login /miler/verify-pin /miler/profile +/miler/duty/current /miler/duty/start /miler/duty/end +/miler/assignments /miler/assignments/{id}/accept | /reject +/miler/bookings /miler/bookings/status +/miler/bookings/{id}/reached /miler/bookings/{id}/parcel +/miler/bookings/{id}/pickup-complete /miler/bookings/{id}/payment +/miler/bookings/{id}/addresses /miler/bookings/{id}/cancel | /skip +/miler/consignments/{id} /miler/consignments/{id}/start-delivery +/miler/consignments/{id}/deliver /miler/consignments/{id}/inward-at-hub +/miler/location /miler/notifications /miler/device-token /miler/support … +``` + +**Therefore:** + +- The customer API is a **sibling namespace, `/customer/*`, on the same host and the + same `booking` / `consignment` records.** A customer booking must become the same + row the Miler app later reads from `/miler/bookings`; a customer "order" must be the + same row as a `consignment`. +- **§9 ("what ops must be able to write") is mostly already built.** `reached`, + `parcel`, `pickup-complete`, `start-delivery`, `deliver`, `inward-at-hub` already + exist. What is missing is not the writes — it is the **derivation of the customer's + 9 stages from those writes** (§8.3) and the read endpoints that expose them. +- Conventions below are **the real ones**, taken from the live service, not invented. + +### 2.1 Corrections to the previous draft of this document + +The earlier version of this file was written before the live backend was inspected. If +you were given that version, these four things were wrong: + +| Was written | Reality | +| --- | --- | +| Base URL `https://api.doormile.in/v1` | `https://api.doormile.com/api/v1` | +| Envelope `{ "data": … }` | `{ "success": bool, "data": …, "message": string }`; lists add `total` | +| A greenfield ops/"Miler app" section | Those endpoints exist; only the customer projection is missing | +| Timestamps assumed epoch millis end-to-end | The live service sends **naive IST wall-clock strings**, sometimes with a spurious trailing `Z` (§3.3) | + +--- + +## 3. Conventions + +| Item | Requirement | +| --- | --- | +| Base URL | `https://api.doormile.com/api/v1` + a staging host with the same contract | +| Namespace | **`/customer/*`** for everything in this document | +| Transport | HTTPS only, TLS 1.2+ | +| Format | JSON, UTF-8 | +| Success envelope | `{ "success": true, "data": , "message": "" }` | +| List envelope | `{ "success": true, "data": [...], "total": , "nextCursor": }` | +| Error envelope | `{ "success": false, "message": "", "error": { "code": "…" } }` | +| Auth | `Authorization: Bearer ` on everything except §4.1–§4.3 | +| Money | Integer **rupees**, not paise. `min: 49` renders as `₹49` | +| Client headers | `X-Client: doormile-cx/+`, `X-Platform: android\|ios` — accept and log | +| Request id | Echo `X-Request-Id` on every response, including errors | +| Display strings | Any human string (`day`, `window`, `expectedDelivery`) is formatted **server-side in IST** | + +### 3.1 Envelope consistency — one thing not to copy + +`/miler/verify-pin` returns its payload **outside** `data`, as +`{success, token, user:{…, profile:{…}}}`. That inconsistency cost the Miler client a +release to discover. **Do not repeat it here.** Every `/customer/*` response — auth +included — puts its payload in `data`. + +### 3.2 Field naming + +The `/miler/*` surface uses lowercase run-together keys (`bookingid`, `createdat`, +`slotstarttime`). The customer client models are **camelCase** (`districtCode`, +`packageCount`, `trackingId`). + +**Preferred: serve `/customer/*` in camelCase** exactly as specified below. If your ORM +makes that expensive, say so — the client will add one mapping layer — but decide now +and freeze it, because a mixed-case response is what forces the client to guess. + +### 3.3 Timestamps — decide this explicitly + +The client currently parses `DateTime.fromMillisecondsSinceEpoch(int)`. +The live service emits naive IST wall-clock strings, occasionally suffixed `Z` for a +timezone they are not in. + +- **Preferred:** `/customer/*` sends **epoch milliseconds, UTC, integer** for + `createdAt`, `history[].at`, `verification.capturedAt`, `deliveredAt`. +- **If you cannot:** send **ISO-8601 with a real offset** (`2026-09-04T14:30:00+05:30`). + Tell us and the client adds a tolerant parser. +- **Never** send a naive local string with a `Z` on it. That is a wrong instant, not a + formatting nit, and it has already produced "yesterday's work shown as today" bugs on + the Miler app. + +### 3.4 Error contract + +Every failure returns the error envelope. The app funnels all failures into one error +state with a Retry, and shows `message` **verbatim** — so it must be customer-safe +English, never an enum key, stack trace or HTML page. + +| HTTP | `error.code` | When | `message` shown | +| --- | --- | --- | --- | +| 400 | `invalid` | Validation failed | "Every destination needs a serviceable state and district" | +| 400 | `invalid_name` | Name < 2 chars at signup | "Enter your full name" | +| 401 | `invalid_otp` | Wrong or expired code | "That code did not match" | +| 401 | `unauthorized` | Missing/expired access token | "Please sign in again" | +| 403 | `forbidden` | Token valid, resource not the caller's | "You do not have access to this" | +| 404 | `not_found` | Unknown reference / state / order | Context-specific | +| 409 | `conflict` | Cancelling after the window; slot filled | "This pickup can no longer be cancelled" | +| 422 | `unserviceable` | District closed between selection and booking | "That district is no longer available" | +| 429 | `rate_limited` | Throttled; include `Retry-After` | "Too many attempts. Try again in a minute" | +| 5xx | `server_error` | Anything else | "Something went wrong" | +| — | `network` | Client-side only | "We could not reach Doormile" | + +### 3.5 Non-functional + +- **Latency:** p95 ≤ 400 ms for every GET in §5 (each sits behind a loading skeleton); + p95 ≤ 1.2 s for `POST /customer/bookings`. +- **Idempotency:** `POST /customer/bookings` and `POST /customer/auth/otp/verify` must + accept `Idempotency-Key` and replay the original response for 24 h. The client + retries on flaky networks; duplicate pickups are unacceptable. +- **Caching:** serviceability supports `ETag`/`If-None-Match`. Slots are volatile — + `Cache-Control: max-age=30` at most. +- **Rate limits:** OTP request ≤ 5 per number per hour; ≤ 3 verify attempts per code. +- **Pagination:** `?limit=&cursor=`, default 20, newest first, `nextCursor` in the + envelope. +- **PII:** phone, email, recipient name/phone and delivery instructions are PII. + Encrypt at rest, redact in logs, never leak across customers. +- **Audit:** every stage transition recorded with actor (miler id / ops user / system), + timestamp and source. The customer timeline is derived from this, so it must be real. +- **Tenancy:** the JWT already carries a `tenantid` claim. Customer tokens must carry + it too, and every read must be tenant-scoped server-side. + +--- + +## 4. Auth & session + +4-digit OTP over phone (primary) or email. **No password anywhere in the app.** +Note this is deliberately *not* the Miler's phone+PIN flow — do not reuse `verify-pin`. + +Phone is currently sent as `+91 98765 43210` (with spaces). **Normalise to E.164 +server-side and accept both**; the client will be tightened to send E.164. + +### 4.1 `POST /customer/auth/otp/request` + +```jsonc +// request +{ "identifier": "+919876543210" } // or "you@example.com" + +// 200 +{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30, "codeLength": 4 } } +``` +- The resend countdown is server-driven; the client stops hardcoding 30 s once this ships. +- Code TTL 5 minutes, single use. +- Errors: `rate_limited`, `invalid`. + +### 4.2 `POST /customer/auth/signup` + +```jsonc +// request +{ "name": "Joe Oommen", "phone": "+919876543210", "email": "joe@example.com" } // email optional + +// 200 — creates the account AND sends the OTP +{ "success": true, "data": { "sent": true, "resendAfterSeconds": 30 } } +``` +- `name` ≥ 2 chars, else `invalid_name`. +- If the phone already exists, **do not error** — treat it as a sign-in and send the + code. The UI has no "account exists" state. Object now if you disagree. + +### 4.3 `POST /customer/auth/otp/verify` + +```jsonc +// request +{ "identifier": "+919876543210", "code": "4821", "name": "Joe Oommen" } // name only on signup + +// 200 +{ + "success": true, + "data": { + "accessToken": "…", "refreshToken": "…", "expiresIn": 3600, + "customer": { "id": "cust_10241", "name": "Joe Oommen", + "phone": "+919876543210", "email": "joe@example.com" } + } +} +``` +- `name`, `phone`, `email` are all rendered on Account and drive the avatar initials. +- **`email` must not be null** — the client types it as `String`. Send `""` if unknown. +- Errors: `invalid_otp`. + +### 4.4 `POST /customer/auth/refresh` · `POST /customer/auth/logout` · `GET /customer/auth/me` + +- Refresh: `{ "refreshToken": "…" }` → new access token; rotation preferred; refresh TTL + 60 days so the customer stays signed in. +- Logout: revokes the refresh token **and** unregisters the device push token. +- `GET /customer/auth/me` → the same `customer` object, for cold-start session restore. + +Session persistence is client work (§Appendix B) but it cannot start until refresh +exists. Ship §4.3 and §4.4 together. + +--- + +## 5. Catalogue & configuration + +These four drive the entire booking form. **Highest priority — nothing else works +without them.** + +### 5.1 `GET /customer/serviceability/states` + +```jsonc +{ "success": true, "data": [ + { "code": "TN", "name": "Tamil Nadu", "districtCount": 6, "transitTag": "Ultra-fast transit" }, + { "code": "KL", "name": "Kerala", "districtCount": 3, "transitTag": "Next-day transit" }, + { "code": "PY", "name": "Puducherry", "districtCount": 0, "transitTag": "Opening soon" } +]} +``` + +| Field | Type | Req | Notes | +| --- | --- | --- | --- | +| `code` | string | yes | Stable; stored on the booking | +| `name` | string | yes | Display name | +| `districtCount` | int | yes | **Count of `available` districts only.** The client hides any state with `0` | +| `transitTag` | string | no | ≤ 22 chars | + +Return `[]` when nothing is serviceable — the app has a designed "no service" state. + +### 5.2 `GET /customer/serviceability/states/{stateCode}/districts` + +```jsonc +{ "success": true, "data": [ + { "code": "TN-CBE", "name": "Coimbatore", "available": true, + "hub": "Coimbatore Central Hub", "promise": "Next-day delivery", + "lat": 11.0168, "lng": 76.9558 }, + { "code": "TN-MDU", "name": "Madurai", "available": false, "note": "Opening soon", + "lat": 9.9252, "lng": 78.1198 } +]} +``` + +| Field | Type | Req | Notes | +| --- | --- | --- | --- | +| `code` / `name` | string | yes | Stable code + display name | +| `available` | bool | yes | `false` districts are not offered | +| `note` | string | no | Why not: "Opening soon", "Paused this week" | +| `hub` | string | no | Serving hub, shown on the destination card | +| `promise` | string | no | "Next-day delivery" / "2-day delivery" | +| `lat` / `lng` | number | no | **Where the hub is.** The route map draws pickup → hub from these. Omit them and the map draws the pickup end only — no line, no destination marker | + +**Return the full list including unavailable districts** — the client filters them out +of the picker but uses their names for a quiet "Coming soon" line. +Unknown `stateCode` → `404 not_found`, "That state is no longer serviceable". + +### 5.3 `GET /customer/pickup-slots?lat=&lng=` + +```jsonc +{ "success": true, "data": [ + { "id": "slot_t_1", "day": "Today", "window": "2:00 – 4:00 PM", "available": true, + "tag": "Fastest pickup", "milersNearby": 4, "caption": "Arriving in approx. 45 mins" }, + { "id": "slot_t_3", "day": "Today", "window": "6:00 – 8:00 PM", "available": false, + "note": "Fully booked" } +]} +``` + +| Field | Type | Req | Notes | +| --- | --- | --- | --- | +| `id` | string | yes | Opaque; sent back as `slotId` | +| `day` | string | yes | "Today" / "Tomorrow" / "Mon, 8 Sep" — **server-formatted, IST** | +| `window` | string | yes | "2:00 – 4:00 PM" (en dash, spaced) | +| `available` | bool | yes | Capacity remaining in the customer's zone | +| `note` | string | no | "Fully booked" | +| `tag` | string | no | At most one slot carries "Fastest pickup" | +| `milersNearby` | int | no | 0 hides the line | +| `caption` | string | no | "Arriving in approx. 45 mins" | + +Slots are **capacity- and location-aware** — use the `lat`/`lng` of the pickup point. +Roughly today + tomorrow; the design expects ~6 windows. +The slot ids must resolve to whatever the Miler assignment engine consumes — a customer +slot that ops cannot staff is worse than no slot. + +### 5.4 `GET /customer/config/booking-limits` + +```jsonc +{ "success": true, "data": { "maxPackages": 20, "maxDestinations": 5 } } +``` + +Nothing in the UI hardcodes these; they exist so ops can vary them by city or tier +without an app release. Must degrade safely (client keeps 20/5 on failure). Never `0`. +If they become per-city, key them off the pickup location and tell us — the client will +re-fetch when the pickup point moves. + +--- + +## 6. Location + +Maps in the customer app are real as of this revision: `flutter_map` over +OpenStreetMap/CARTO raster tiles, and `geolocator` for the device fix. No key is held +by the client, which matches the decision already taken on the Miler side. The +coordinates reaching these endpoints are real GPS coordinates. + +### 6.1 `GET /customer/places/reverse-geocode?lat=&lng=` + +```jsonc +{ "success": true, "data": { "title": "12 Nehru Street", + "sub": "Gandhipuram, Coimbatore 641012", "lat": 11.0183, "lng": 76.9725 } } +``` +`title` = short label (≤ 32 chars), `sub` = full address line. **Both required, neither +nullable.** Used to pre-fill the pickup point from the device location. + +**This is the hottest endpoint in the booking flow.** The pickup screen puts the pin at +the centre of the map and moves the map under it; every time the map comes to rest, this +is called and the answer is what the customer reads in the box below. Target **p95 under +300 ms** and cache by rounded coordinate (~5 decimal places is more precision than any +door needs). The client already debounces 320 ms after the map settles and discards a +response that a later drag has superseded, so you will not get a request per frame — but +you will get one per correction, and each one is on the critical path to "Next". + +### 6.2 `GET /customer/places/search?q=&lat=&lng=` + +```jsonc +{ "success": true, "data": [ { "title": "Brookefields Mall", + "sub": "Brookebond Road, Coimbatore 641001", "lat": 10.9987, "lng": 76.9628 } ] } +``` +- Empty `q` → the customer's recent/saved places (≤ 4). The search sheet opens on this. +- Bias to `lat`/`lng` and to serviceable areas. +- **Proxy the geocoder through the backend** — the Miler app's Google Maps key was + revoked and that side now runs on OSM/OSRM with no key. Do not hand the customer app + a key to hold; serve results and cache them. + +--- + +## 7. Fare estimate + +### `POST /customer/fare/estimate` + +```jsonc +// request +{ "pickup": { "lat": 11.0183, "lng": 76.9725 }, + "destinations": [ + { "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 2 }, + { "stateCode": "KL", "districtCode": "KL-EKM", "packageCount": 1 } ] } + +// 200 +{ "success": true, "data": { + "min": 167, "max": 267, + "paymentMethod": "UPI · Cash at doorstep", + "parcel": "3 boxes (up to 3 kg each)", + "routeKm": 6.4 } } +``` + +| Field | Type | Notes | +| --- | --- | --- | +| `min` / `max` | int (₹) | Rendered "₹167 – ₹267". An **estimate range**, never final | +| `paymentMethod` | string | Shown as-is on Review and the receipt | +| `parcel` | string | What is being priced, e.g. "Standard box (up to 3 kg)" | +| `routeKm` | number | Pickup→destination distance, drives the route outline and receipt | + +- Called on every route or package-count change — must be cheap and cacheable. +- A failed estimate **must not block booking**; the client swallows the error. +- Must be the **combined** price for one visit, including multi-stop uplift. + +--- + +## 8. Stages, milestones and how they are derived + +### 8.1 The nine stages + +The backend owns nine operational stages; the customer sees seven milestones. The +rollup happens on the client — **always send the raw stage key**, lowercase snake_case, +spelled exactly as below. An unknown key silently falls back to `booked` on the client, +so new keys require a client release. + +| # | Stage key | Customer milestone | Must also be true | +| --- | --- | --- | --- | +| 0 | `booked` | Pickup booked | Set by `POST /customer/bookings` | +| 1 | `assigned` | Miler assigned | `miler` object present | +| 2 | `on_the_way` | Pickup in progress | `milerDistanceKm`, `milerEtaMinutes` set | +| 3 | `arrived` | Pickup in progress | Distance 0, ETA 0. **Last cancellable stage** | +| 4 | `picked_up` | Package collected | `verification` populated; `amountPaid` settles | +| 5 | `order_created` | Package collected | **`trackingId` minted per destination**; `expectedDelivery` set | +| 6 | `in_transit` | In transit | Per-order from here on | +| 7 | `out_for_delivery` | Out for delivery | `deliveryAgent` present | +| 8 | `delivered` | Delivered | `deliveredAt` set; booking `status` → `completed` | + +- **Stages 0–5 belong to the booking; 6–8 belong to each order** and may differ between + destinations of the same booking. The client already renders independent journeys. +- The timeline is built from a **history array** — one entry per stage actually reached, + with the real timestamp. **Do not synthesise or backfill times.** (The mock does; that + is a prototype affordance, not a spec.) +- `status` (`active` / `completed` / `cancelled`) is derived but must be sent + explicitly. Do not make the client infer it. + +### 8.2 Milestone rollup (client-side, for your reference) + +``` +booked → Pickup booked +assigned → Miler assigned +on_the_way | arrived → Pickup in progress +picked_up | order_created → Package collected +in_transit → In transit +out_for_delivery → Out for delivery +delivered → Delivered +``` + +### 8.3 Derivation from the writes that already exist ← **the actual work** + +| Existing Miler write | Customer stage it must produce | +| --- | --- | +| assignment created / `POST /miler/assignments/{id}/accept` | `assigned` (+ `miler` from the accepting rider) | +| rider goes on-route (duty + location stream) | `on_the_way` (+ distance/ETA from `/miler/location`) | +| `POST /miler/bookings/{id}/reached` | `arrived` — **cancellation closes here** | +| `POST /miler/bookings/{id}/parcel` (weight + photos) | populates `verification` | +| `POST /miler/bookings/{id}/pickup-complete` | `picked_up`, then `order_created`; **mint one consignment + `trackingId` per destination**; settle `amountPaid` | +| `POST /miler/consignments/{id}/inward-at-hub` | `in_transit` (per order) | +| `POST /miler/consignments/{id}/start-delivery` | `out_for_delivery` (+ `deliveryAgent`) | +| `POST /miler/consignments/{id}/deliver` | `delivered`, `deliveredAt`, booking → `completed` | +| `POST /miler/bookings/{id}/cancel` \| `/skip`, or ops cancel | `cancelled` + `cancelReason` | + +If any of these does not currently emit an event the customer projection can read, that +is the gap to close first. + +### 8.4 Known gaps in the existing backend that block this + +Found while integrating the Miler app; each one has a customer-visible consequence: + +| Gap | Customer-app consequence | +| --- | --- | +| Booking carries no `assignmentid` / `consignmentid` (the app assumes `== bookingid`) | Multi-destination bookings cannot mint N orders; §1 breaks | +| No COD / collection amount on the booking object | `amountPaid` and the receipt cannot settle | +| No per-stop `type` (pickup vs delivery) or `step` ordering | Stages 6–8 cannot be attributed to the right order | +| No skip/resume and no customer-side cancel endpoint | §9.4 cannot be built | +| Parcel weight/photos not exposed on any read | `verification` block stays empty; the receipt loses its evidence | + +--- + +## 9. Bookings + +### 9.1 `POST /customer/bookings` — create the pickup + +```jsonc +// request (Idempotency-Key header required) +{ + "pickup": { "title": "12 Nehru Street", "sub": "Gandhipuram, Coimbatore 641012", + "lat": 11.0183, "lng": 76.9725 }, + "slotId": "slot_t_1", + "destinations": [ + { "stateCode": "TN", "districtCode": "TN-MAA", "packageCount": 2, + "details": { // every field optional, may be omitted entirely + "street": "12th Main", "building": "3B", "landmark": "Near bus stand", + "recipientName": "Meera S", "recipientPhone": "+919884412210", + "instructions": "Call before delivery", + "pin": { "lat": 13.0827, "lng": 80.2707 } } }, + { "stateCode": "KL", "districtCode": "KL-EKM", "packageCount": 1 } + ], + "estimate": { "min": 167, "max": 267 } // what the customer was shown, for dispute audit +} +``` + +Server-side validation — mirror exactly, the client displays your `message`: + +| Rule | Error | +| --- | --- | +| ≥ 1 destination, each with serviceable `stateCode` + `districtCode` | `400 invalid` — "Every destination needs a serviceable state and district" | +| `slotId` present and still available | `400 invalid` — "Pick a pickup slot" / `409 conflict` — "That pickup window just filled up" | +| `packageCount` ≥ 1, total ≤ `maxPackages` | `400 invalid` — "Up to 20 packages per pickup" | +| destinations ≤ `maxDestinations` | `400 invalid` — "Up to 5 destinations per pickup" | +| district still `available` | `422 unserviceable` | + +Response: the **full booking object** (§9.3) with `reference` `DM-######`, +`stage: "booked"`, `status: "active"`, `cancellable: true`, a one-entry `history`, and +**no `trackingId` on any destination**. + +### 9.2 `GET /customer/bookings?status=active|completed|cancelled&limit=&cursor=` + +Backs the Orders tabs, Home's "Recent" (3 most recent non-active) and pull-to-refresh. +Array of booking objects, newest first. + +### 9.3 `GET /customer/bookings/{reference}` — the canonical object + +The single most important response in the API: the tracking screen and the receipt are +both rendered from it. + +```jsonc +{ "success": true, "data": { + "reference": "DM-482913", + "stage": "in_transit", + "status": "active", + "cancellable": false, + "createdAt": 1757056800000, + + "pickup": { "title": "12 Nehru Street", "sub": "Gandhipuram, Coimbatore 641012", + "lat": 11.0183, "lng": 76.9725 }, + "slotId": "slot_t_1", + + "destinations": [ + { "stateCode": "TN", "stateName": "Tamil Nadu", + "districtCode": "TN-MAA", "districtName": "Chennai", + "packageCount": 2, + "district": { "code": "TN-MAA", "name": "Chennai", "available": true, + "hub": "Chennai Guindy Hub", "promise": "Next-day delivery" }, + "details": { "street": "12th Main", "recipientName": "Meera S", + "recipientPhone": "+919884412210" }, + "trackingId": "DMX10482913", // null until order_created + "stage": "in_transit", // null until order_created + "verification": { // null until picked_up + "weightKg": 2.8, + "photos": ["https://cdn.doormile.com/pv/abc.jpg"], + "capturedAt": 1757060400000, + "capturedBy": "Arun Kumar" } } + ], + + "miler": { "name": "Arun Kumar", "vehicle": "TN 37 BX 4412", "phone": "+919000011223", + "rating": 4.9, "trips": 1240, "vehicleType": "E-Scooter" }, + "deliveryAgent": null, // populated from out_for_delivery + + "milerDistanceKm": null, // set during on_the_way / arrived + "milerEtaMinutes": null, + "milersInZone": 3, // shown while finding a Miler + "routeKm": 6.4, + + "expectedDelivery": "Thu, 12 Sep", // server-formatted, IST + "fare": { "min": 49, "max": 64, "paymentMethod": "UPI · Cash at doorstep", + "parcel": "Standard box (up to 3 kg)" }, + "amountPaid": 64, // null until picked_up + "deliveredAt": null, + "cancelReason": null, + + "history": [ + { "stage": "booked", "at": 1757056800000 }, + { "stage": "assigned", "at": 1757057400000 }, + { "stage": "on_the_way", "at": 1757058900000 }, + { "stage": "arrived", "at": 1757059800000 }, + { "stage": "picked_up", "at": 1757060400000 }, + { "stage": "order_created", "at": 1757060460000 }, + { "stage": "in_transit", "at": 1757062200000 } + ] +}} +``` + +Field notes that matter: + +- `destinations[].stateName` / `districtName` are **required** — the client renders + "Chennai, Tamil Nadu" from them and does not look codes up. +- `pickup` and `slotId` must be **present on every booking, including cancelled ones** — + the client parser types them non-nullable and will throw on `null`. +- `verification.photos` must be **fetchable URLs** (signed, ≥ 15 min TTL), one per + package. `weightKg` is the number the price settled on; shown as "2.8 kg". +- `miler.phone` powers a **Call Miler** action. Real dialable number or masked-calling + proxy — say which. Masked preferred. +- `amountPaid` is the settled total in ₹, present from `picked_up`. The receipt renders + `amountPaid − fare.min` as "Weight adjustment", so **keep `fare` on the booking + forever**, even after settlement. +- `expectedDelivery` is a display string, formatted server-side in IST. +- `history` is append-only and ordered; every timestamp on the timeline comes from it. + +### 9.4 `POST /customer/bookings/{reference}/cancel` + +```jsonc +// request +{ "reason": "Package not ready" } // optional; free text or one of the 5 presets + +// 200 +{ "success": true, "data": { "reference": "DM-482913", "status": "cancelled", + "cancelReason": "Package not ready" } } +``` + +- Preset reasons in the UI: *Booked by mistake · Package not ready · Sending it another + day · Changed the destination · Other*. `reason` may be `null`. +- **Allowed only through `arrived`.** From `picked_up` onward return `409 conflict`. + The server is the authority; `cancellable` on the booking mirrors the same policy so + the UI can hide the button, but the server must re-check. +- Cancels the **whole pickup**, every destination. No partial cancellation in v1. +- The UI promises *"You can cancel free of charge until the Miler collects your + package."* — **confirm no fee applies in that window.** + +### 9.5 `PATCH /customer/bookings/{reference}/destinations/{index}` + +The customer can fill in street / landmark / recipient / instructions / map pin **after** +booking, up to collection. + +```jsonc +{ "street": "12th Main", "landmark": "Near bus stand", + "recipientName": "Meera S", "recipientPhone": "+919884412210", + "instructions": "Call before delivery", "pin": { "lat": 13.08, "lng": 80.27 } } +``` +- Any subset; `null` clears a field. +- Accept until `picked_up`; after that `409 conflict`. +- **These edits must reach the Miler app in near real time** — that app reads addresses + via `/miler/bookings/{id}/addresses`, so the write must land on the same record. + +### 9.6 `GET /customer/orders/{trackingId}` + +One order by tracking number, returning the booking object focused on that destination. +Needed for push deep links. A slimmer order object is acceptable if you prefer — say so; +the client currently reuses the booking shape. + +--- + +## 10. Live updates + +The tracking screen currently advances through a debug stepper. Production needs real +events. **Required for launch:** + +1. **Push (FCM + APNs)** — one notification per customer-visible *milestone* change: + + ```jsonc + { "type": "stage_change", "reference": "DM-482913", + "trackingId": "DMX10482913", // null before order_created + "stage": "out_for_delivery", + "title": "Out for delivery", + "body": "Arriving today at the delivery address.", + "deepLink": "doormile://track/DM-482913" } + ``` + - `POST /customer/devices` registers `{ token, platform, appVersion }`; logout + unregisters. (The Miler side already has `/miler/device-token` — mirror it.) + - Do **not** notify on every operational stage: `on_the_way` and `order_created` roll + up on the timeline. Agree the notification set with product. + - ⚠️ The client has **no deep-link intent filter yet** (§Appendix B) — send the + `deepLink` field from day one; the client will start honouring it. + +2. **Polling fallback:** `GET /customer/bookings/{reference}` is polled while tracking + is open. Make it cheap; support `If-None-Match` → `304`. + +3. **Phase 2, optional:** SSE/WebSocket for live Miler position while `on_the_way`. + The Miler app already streams to `/miler/location`, so the data exists. The customer + UI shows distance and ETA and will simply not update them without this. + +--- + +## 11. Deliverables from the backend team + +1. **OpenAPI 3.1 spec** for §4–§10 plus a Postman collection. +2. **Staging environment** seeded to mirror the mock, because these exact cases back our + widget tests and design QA: Tamil Nadu / Kerala / Karnataka open; **Puducherry + serviceable with no open district**; **Madurai / Kozhikode / Mangaluru unavailable + with a reason**; at least one fully-booked slot. +3. **Test accounts + a fixed staging OTP** (e.g. `1234`) so automated tests can sign in. +4. **A way to force a booking to any stage on staging** (ops endpoint or console button). + Every tracking state must be reachable for QA — this is what lets us delete the debug + stepper. +5. Documented error responses, ideally error injection, so the app's empty / error / + retry states can be verified against the real service. +6. **A written answer to §13.** + +## 12. Suggested delivery order + +| Phase | Endpoints | Unblocks | +| --- | --- | --- | +| **1** | §5.1 §5.2 §5.3 §5.4 | The whole booking form. Highest priority | +| **2** | §4 auth (request / verify / refresh / me) | Real sign-in + session persistence | +| **3** | §7 estimate, §9.1 create, §9.2 list, §9.3 detail | End-to-end booking | +| **4** | §8.3 stage derivation from the existing Miler writes | Real tracking; removes the debug stepper | +| **5** | §9.4 cancel, §9.5 details patch | Feature-complete v1 | +| **6** | §10 push, §6 places | Live updates and real pickup-point search | + +Phase 4 is the one with hidden cost — it depends on §8.4 being closed first. + +## 13. Open questions — please answer in writing + +1. **Payment.** The app shows "UPI · Cash at doorstep" and an `amountPaid` but has no + payment step. Is v1 cash/UPI-at-the-door, settled outside the app? If an in-app + payment or payment link is coming, we need the contract now. Note the money at the + door may be the customer's own COD collection — confirm who owns that flow. +2. **Masked calling** for `miler.phone` — real number or proxy? +3. **Partial pickup.** What happens if the Miler collects some destinations but not all? + The model has no partial-pickup state today. +4. **Failed delivery / reattempt.** Does it exist operationally? There is no customer + screen for it, and no stage key. If it exists we need both. +5. **Saved addresses, notification preferences, payment methods** — rows exist on the + Account screen with no backend. In v1 scope? If yes we need + `GET/POST/DELETE /customer/addresses` and a preferences endpoint. +6. **Support.** "Need help with this order?" is a dead end today. Chat, ticket API + (the Miler side has `/miler/support`), or a phone number? +7. **Slot capacity semantics.** Is `available` per zone, per Miler count, or a hard cap? + It decides how often the client re-fetches before confirming. +8. **Pricing.** Who owns the estimate formula, and does the customer see a breakdown + when `amountPaid` exceeds `fare.max`? +9. **Cancellation fee** — confirmed free through `arrived`? +10. **Retention** for parcel photos and PII. +11. **Tenancy.** Does the customer app serve both the logistics line and the meal line, + or logistics only in v1? The Miler app switches its entire mode on `tenantid`. + +--- + +## Appendix A — Client source of truth + +| Contract element | File | +| --- | --- | +| Every model + `fromJson` | `lib/data/models.dart` | +| Every endpoint the app calls (currently mocked) | `lib/data/doormile_api.dart` | +| Stage side-effects the backend must perform | `AppState._applyStage`, `lib/state/app_state.dart` | +| End-to-end flow the contract must satisfy | `test/booking_flow_test.dart` (18 tests) | + +To go live, each method body in `DoormileApi` becomes a network call and the return +types stay identical. No screen changes are needed. + +## Appendix B — Client-side work to do alongside this + +Not backend scope, listed so nobody plans around capabilities the app does not yet have: + +- **`pubspec.yaml` still has no network dependency** — no `http`, no + `shared_preferences`, no push SDK. Those three sections need packages added first. + Maps and geolocation are no longer on this list: `flutter_map` (OpenStreetMap/CARTO + tiles, no key) and `geolocator` are in and working on both maps. +- `District.lat`/`lng` (§5.2) are parsed and drive the route map. Until the API sends + them the app falls back to a built-in table of district centroids. +- `Booking.fromJson` does not yet parse `miler`, `deliveryAgent`, `fare`, `verification`, + `history`, `amountPaid`, `deliveredAt`, `status`, per-destination `details` / + `district` / `stage` (§9.3). +- `Destination` and `DeliveryDetails` have no `fromJson`/`toJson` yet. +- No deep-link intent filter in `AndroidManifest.xml` and no URL types on iOS, so + `doormile://track/…` does nothing today (§10). +- Location permissions **are** declared now (`ACCESS_FINE_LOCATION` / + `ACCESS_COARSE_LOCATION`, `NSLocationWhenInUseUsageDescription`) and the pickup pin + comes from the real device fix; only the address behind it is still mocked. +- Session persistence is unbuilt — it depends on §4.4. +- The tracking screen's `_StageStepper` is the prototype stand-in for real events; + deleting it is the only client change needed once §10 lands. diff --git a/integration_test/book_one_test.dart b/integration_test/book_one_test.dart new file mode 100644 index 0000000..b0bf342 --- /dev/null +++ b/integration_test/book_one_test.dart @@ -0,0 +1,89 @@ +import 'package:doormile_cx/data/location_service.dart'; +import 'package:doormile_cx/main.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +/// Drives the real app on the simulator all the way through a booking, then +/// holds on the confirmation so it can be photographed from outside. +/// +/// Run against the dev data, which is where any 4-digit code signs in and the +/// serviceability / slots / booking all exist without a server: +/// +/// flutter test integration_test/book_one_test.dart \ +/// -d --dart-define=DM_MOCK=true +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('books a pickup to Chennai and lands on the confirmation', + (tester) async { + // Deterministic pin, so the pickup step never stops on an OS location + // dialog and resolves the same way every run. + LocationService.instance = FixedLocationService(); + + await tester.pumpWidget(const DoormileApp()); + await tester.pumpAndSettle(const Duration(milliseconds: 400)); + + // The app runs continuous animations, so pumpAndSettle would spin forever. + // Pump fixed slices instead. + Future hold([int ms = 900]) async { + await tester.pump(); + await tester.pump(Duration(milliseconds: ms)); + await tester.pump(const Duration(milliseconds: 400)); + } + + // ---- sign in: 9876543210 + any 4-digit code ---------------------------- + await tester.enterText(find.byType(TextField).first, '9876543210'); + await hold(300); + await tester.tap(find.text('Continue')); + await hold(); + + final boxes = find.byType(TextField); + for (var i = 0; i < 4; i++) { + await tester.enterText(boxes.at(i), '1'); + await tester.pump(); + } + await hold(1200); + expect(find.text('Where should we pick up?'), findsOneWidget); + debugPrint('DRIVER: signed in, on Home'); + + // ---- BOOK → confirm the pin ----------------------------------- + await tester.tap(find.text('BOOK')); + await hold(1200); + await tester.tap(find.text('Next')); + await hold(); + expect(find.text('Where are you sending it?'), findsOneWidget); + + // ---- destination: Tamil Nadu ▸ Chennai --------------------------------- + await tester.tap(find.text('Tamil Nadu')); + await hold(); + await tester.tap(find.text('Chennai')); + await hold(); + expect(find.text('Chennai, Tamil Nadu'), findsOneWidget); + await tester.tap(find.text('Next')); + await hold(); + + // ---- pickup window ----------------------------------------------------- + expect(find.text('When should we come?'), findsOneWidget); + await tester.tap(find.text('2:00 – 4:00 PM').first); + await hold(); + await tester.tap(find.text('Review booking')); + await hold(1200); + + // ---- review → book ----------------------------------------------------- + expect(find.text('Review pickup details'), findsOneWidget); + debugPrint('DRIVER: on review'); + await tester.tap(find.text('Book Pickup')); + await hold(1600); + + // ---- confirmation ------------------------------------------------------ + expect(find.text('Pickup booked'), findsOneWidget); + debugPrint('DRIVER: BOOKED — holding on confirmation'); + + // Hold so the confirmation can be photographed from outside the test. + for (var i = 0; i < 50; i++) { + await tester.pump(const Duration(milliseconds: 500)); + } + }); +} diff --git a/integration_test/otp_send_test.dart b/integration_test/otp_send_test.dart deleted file mode 100644 index de202d5..0000000 --- a/integration_test/otp_send_test.dart +++ /dev/null @@ -1,67 +0,0 @@ -// Confirms Firebase Phone Auth actually dispatches an OTP SMS. -// -// This must run on a real device or emulator with Google Play services — -// `flutter_test`'s headless VM has no platform channels, so FirebaseAuth -// would throw MissingPluginException there. Each run sends one real, billed -// SMS (Blaze plan) to the hardcoded number below. -// -// Run with a device attached: -// flutter test integration_test/otp_send_test.dart -d -// (list device ids with `flutter devices`) -import 'dart:async'; - -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - testWidgets('Firebase dispatches OTP SMS to a real phone number', - (tester) async { - await Firebase.initializeApp(); - - const phoneNumber = '+916374946729'; - final completer = Completer(); - - await FirebaseAuth.instance.verifyPhoneNumber( - phoneNumber: phoneNumber, - timeout: const Duration(seconds: 60), - verificationCompleted: (PhoneAuthCredential credential) { - if (!completer.isCompleted) { - completer.complete('verificationCompleted (device auto-verified)'); - } - }, - verificationFailed: (FirebaseAuthException e) { - if (!completer.isCompleted) { - completer.completeError('verificationFailed: ${e.code} — ${e.message}'); - } - }, - codeSent: (String verificationId, int? resendToken) { - if (!completer.isCompleted) { - completer.complete('codeSent (verificationId: $verificationId)'); - } - }, - codeAutoRetrievalTimeout: (String verificationId) { - if (!completer.isCompleted) { - completer.complete( - 'codeAutoRetrievalTimeout (verificationId: $verificationId) — request was accepted, auto-retrieval just timed out'); - } - }, - ); - - final result = await completer.future.timeout( - const Duration(seconds: 45), - onTimeout: () => throw TimeoutException( - 'No callback fired within 45s — check phone provider is enabled, ' - 'Blaze billing is active, and SHA-1/SHA-256 fingerprints are ' - 'registered in Firebase console.'), - ); - - debugPrint('OTP SEND RESULT: $result'); - expect(result, isNotEmpty); - expect(result, isNot(contains('verificationFailed'))); - }); -} diff --git a/integration_test/receipt_walkthrough_test.dart b/integration_test/receipt_walkthrough_test.dart new file mode 100644 index 0000000..ca53dee --- /dev/null +++ b/integration_test/receipt_walkthrough_test.dart @@ -0,0 +1,52 @@ +import 'package:doormile_cx/data/location_service.dart'; +import 'package:doormile_cx/main.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +/// Walks the app the way a customer does — sign in, Orders, Completed, open an +/// order — then holds on the receipt so it can be captured from the simulator. +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Orders ▸ Completed ▸ order details', (tester) async { + // Deterministic pin: the walkthrough must never stop on an OS permission + // dialog, and the receipt it captures should look the same every run. + LocationService.instance = FixedLocationService(); + + await tester.pumpWidget(const DoormileApp()); + await tester.pumpAndSettle(const Duration(milliseconds: 400)); + + Future hold([int ms = 900]) async { + await tester.pump(); + await tester.pump(Duration(milliseconds: ms)); + await tester.pump(const Duration(milliseconds: 400)); + } + + await tester.enterText(find.byType(TextField).first, '9876543210'); + await hold(300); + await tester.tap(find.text('Continue')); + await hold(); + + final boxes = find.byType(TextField); + for (var i = 0; i < 4; i++) { + await tester.enterText(boxes.at(i), '1'); + await tester.pump(); + } + await hold(1200); + + await tester.tap(find.text('Orders')); + await hold(); + await tester.tap(find.text('Completed')); + await hold(); + await tester.tap(find.text('Salem, Tamil Nadu')); + await hold(1200); + + expect(find.text('Collected at your door'), findsOneWidget); + + // Hold the screen so it can be photographed from outside the test. + for (var i = 0; i < 40; i++) { + await tester.pump(const Duration(milliseconds: 500)); + } + }); +} diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/ios/Flutter/Debug.xcconfig +++ b/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/ios/Flutter/Release.xcconfig +++ b/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..9d7ef08 --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '15.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..e910b22 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,22 @@ +PODS: + - Flutter (1.0.0) + - flutter_secure_storage (6.0.0): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" + +SPEC CHECKSUMS: + Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + +PODFILE CHECKSUM: 3ba3a73b1b12adcf321a227a745625fe0888cc82 + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index f3490c3..9ad961e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 2F12380702D6E97BF7450D81 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0AF8895E84372B2D04AFDCA5 /* Pods_RunnerTests.framework */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; @@ -16,6 +17,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + C0A6727EE7720D92A4720F5E /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E14CFDD38B5366586D5AC26 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,16 +44,20 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0AF8895E84372B2D04AFDCA5 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0E14CFDD38B5366586D5AC26 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 616F6321C94BBA5CDD82420F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8FCF3D3645BD1E84A1204C94 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -59,6 +65,10 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C1A527F64F20FEEB253FFFD6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + DF60BCAD13C4C80F06068E09 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + EA2818836B4C09014511F977 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + F924DF51DE041C76CD32F32B /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -67,12 +77,30 @@ buildActionMask = 2147483647; files = ( 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + C0A6727EE7720D92A4720F5E /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F43E7E2DC7546162795A628D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2F12380702D6E97BF7450D81 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 22CADCB5B09B18040DFC0AB1 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 0E14CFDD38B5366586D5AC26 /* Pods_Runner.framework */, + 0AF8895E84372B2D04AFDCA5 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -81,6 +109,20 @@ path = RunnerTests; sourceTree = ""; }; + 3C9FEBC8493AD9D7086A4B88 /* Pods */ = { + isa = PBXGroup; + children = ( + 616F6321C94BBA5CDD82420F /* Pods-Runner.debug.xcconfig */, + F924DF51DE041C76CD32F32B /* Pods-Runner.release.xcconfig */, + C1A527F64F20FEEB253FFFD6 /* Pods-Runner.profile.xcconfig */, + 8FCF3D3645BD1E84A1204C94 /* Pods-RunnerTests.debug.xcconfig */, + EA2818836B4C09014511F977 /* Pods-RunnerTests.release.xcconfig */, + DF60BCAD13C4C80F06068E09 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -100,6 +142,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 3C9FEBC8493AD9D7086A4B88 /* Pods */, + 22CADCB5B09B18040DFC0AB1 /* Frameworks */, ); sourceTree = ""; }; @@ -135,8 +179,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + D01241037DD5443573E9F97E /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + F43E7E2DC7546162795A628D /* Frameworks */, ); buildRules = ( ); @@ -152,12 +198,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + BE66DEDBA0C5FFBCF1C75411 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 410EC11176A472C10A395125 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -201,7 +249,7 @@ ); mainGroup = 97C146E51CF9000F007C117D; packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; @@ -251,6 +299,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 410EC11176A472C10A395125 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -266,6 +331,50 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + BE66DEDBA0C5FFBCF1C75411 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + D01241037DD5443573E9F97E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -360,9 +469,10 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; @@ -382,7 +492,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile; + PRODUCT_BUNDLE_IDENTIFIER = in.doormile.doormileCx; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -392,13 +502,14 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 8FCF3D3645BD1E84A1204C94 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = in.doormile.doormileCx.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -409,13 +520,14 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = EA2818836B4C09014511F977 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = in.doormile.doormileCx.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -424,13 +536,14 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = DF60BCAD13C4C80F06068E09 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = in.doormile.doormileCx.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -441,7 +554,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -486,10 +599,11 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -498,7 +612,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -537,9 +651,10 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; @@ -561,7 +676,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile; + PRODUCT_BUNDLE_IDENTIFIER = in.doormile.doormileCx; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -583,7 +698,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile; + PRODUCT_BUNDLE_IDENTIFIER = in.doormile.doormileCx; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -627,7 +742,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 3dde002..c30b367 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,6 +1,5 @@ import Flutter import UIKit -import GoogleMaps @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @@ -8,8 +7,6 @@ import GoogleMaps _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - // Replace with your key from Google Cloud Console - GMSServices.provideAPIKey("YOUR_GOOGLE_MAPS_API_KEY") return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d0d98aa..d36b1fa 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1 +1,122 @@ -{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index 7e1d2ed..dc9ada4 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7595a7e..7353c41 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 7738947..797d452 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 64124ad..6ed2d93 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index b5889a4..4cd7b00 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index d8f775a..fe73094 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 5cf2162..321773c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 7738947..797d452 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 5340d2d..502f463 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index f859f53..0ec3034 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png deleted file mode 100644 index 8af4782..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png deleted file mode 100644 index 536e923..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png deleted file mode 100644 index ebb7ce3..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png deleted file mode 100644 index 246122f..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index f859f53..0ec3034 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index 79add0b..e9f5fea 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png deleted file mode 100644 index c00f9f5..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png deleted file mode 100644 index 82a9ded..0000000 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 1512576..84ac32a 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index ff6cacb..8953cba 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 9a70464..0467bf1 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json deleted file mode 100644 index 9f447e1..0000000 --- a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "background.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png b/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png deleted file mode 100644 index 8e21404..0000000 Binary files a/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png and /dev/null differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json index 00cabce..0bedcf2 100644 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -1,23 +1,23 @@ { "images" : [ { - "filename" : "LaunchImage.png", "idiom" : "universal", + "filename" : "LaunchImage.png", "scale" : "1x" }, { - "filename" : "LaunchImage@2x.png", "idiom" : "universal", + "filename" : "LaunchImage@2x.png", "scale" : "2x" }, { - "filename" : "LaunchImage@3x.png", "idiom" : "universal", + "filename" : "LaunchImage@3x.png", "scale" : "3x" } ], "info" : { - "author" : "xcode", - "version" : 1 + "version" : 1, + "author" : "xcode" } } diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png index 9a2c0a5..9da19ea 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png index 3e7236c..9da19ea 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png index cd98261..9da19ea 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard index d53ffb9..f2e259c 100644 --- a/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -16,19 +16,13 @@ - - + + - - - - - - - - + + @@ -38,7 +32,6 @@ - - + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 2c0ecc0..c87b864 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -1,72 +1,72 @@ + + CADisableMinimumFrameDurationOnPhone + + NSLocationWhenInUseUsageDescription + Doormile uses your location to drop the pickup pin at your door, so a Miler can find you. + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Doormile + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Doormile + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Doormile - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - doormile - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIStatusBarHidden + UIApplicationSupportsMultipleScenes + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart deleted file mode 100644 index 30d2080..0000000 --- a/lib/core/constants/api_constants.dart +++ /dev/null @@ -1,30 +0,0 @@ -class ApiConstants { - static const String baseUrl = 'https://api.doormile.com/api/v1'; - - // ---- Auth ---- - static const String register = '$baseUrl/customer/register'; - static const String login = '$baseUrl/customer/login'; - static const String verifyPin = '$baseUrl/customer/verify-pin'; - static const String resetPin = '$baseUrl/customer/reset-pin'; - static const String logout = '$baseUrl/customer/logout'; - - // ---- Profile ---- - static const String profile = '$baseUrl/customer/profile'; - - // ---- Pickup Locations ---- - static const String locations = '$baseUrl/customer/locations'; - static String updateLocation(String id) => '$baseUrl/customer/locations/$id'; - static String deleteLocation(String id) => '$baseUrl/customer/locations/$id'; - - // ---- Pickups (Bookings) ---- - static const String createPickup = '$baseUrl/customer/bookings'; - static const String getPickups = '$baseUrl/customer/bookings'; - static String getPickupDetails(String bookingId) => '$baseUrl/customer/bookings/$bookingId'; - static String cancelPickup(String bookingId) => '$baseUrl/customer/bookings/$bookingId/cancel'; - - // ---- Pricing ---- - static const String checkPrice = '$baseUrl/pricing/check'; - - // ---- Tracking ---- - static String trackPickup(String trackingNo) => '$baseUrl/customer/track/$trackingNo'; -} diff --git a/lib/core/theme/app_colors.dart b/lib/core/theme/app_colors.dart deleted file mode 100644 index 0079732..0000000 --- a/lib/core/theme/app_colors.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:flutter/material.dart'; - -class AppColors { - AppColors._(); - - // Backgrounds & Surfaces - static const surface = Color(0xFFF7F4F2); - static const surfaceDim = Color(0xFFE8DED8); - static const surfaceContainerLowest = Color(0xFFFFFFFF); - static const surfaceContainerLow = Color(0xFFFCFAF8); - static const surfaceContainer = Color(0xFFF0E8E3); - static const surfaceContainerHigh = Color(0xFFE6DAD3); - static const surfaceContainerHighest = Color(0xFFD8C8BF); - static const surfaceVariant = Color(0xFFEDE3DD); - - // Text Colors - static const onSurface = Color(0xFF271D1B); - static const onSurfaceVariant = Color(0xFF756762); - static const outline = Color(0xFFA9958D); - static const outlineVariant = Color(0xFFD8C8BF); - static const inverseSurface = Color(0xFF271D1B); - - // Primary (brand red) - static const primary = Color(0xFFC31723); - static const primaryContainer = Color(0xFF7D1017); - static const onPrimary = Color(0xFFFFFFFF); - static const onPrimaryContainer = Color(0xFFFFEDEE); - static const primaryFixed = Color(0xFFFFE9E7); - static const primaryFixedDim = Color(0xFFFFCFCB); - - // Secondary (blue) - static const secondary = Color(0xFF126E82); - static const secondaryContainer = Color(0xFFDDF4F4); - static const secondaryFixed = Color(0xFFC6ECEC); - static const secondaryFixedDim = Color(0xFF9FDADB); - static const onSecondaryFixedVariant = Color(0xFF064A5A); - - // Tertiary (eco green) - static const tertiary = Color(0xFF16824E); - static const tertiaryContainer = Color(0xFFE7F7ED); - static const tertiaryFixed = Color(0xFFDCFCE7); - static const tertiaryFixedDim = Color(0xFFBBF7D0); - static const onTertiaryFixedVariant = Color(0xFF14532D); - - // Status / Warning - static const error = Color(0xFFDC2626); - static const errorContainer = Color(0xFFFEE2E2); - static const onErrorContainer = Color(0xFF7F1D1D); - static const warning = Color(0xFFFF7A00); // Orange - static const success = Color(0xFF16A34A); // Green - - // Journey accents (brand narrative) - static const firstMile = Color(0xFFEA580C); // orange — pickup - static const midMile = Color(0xFF0E7C7B); // teal — transit - static const lastMile = Color(0xFF1D4EDB); // blue — delivery -} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart deleted file mode 100644 index a4f69b9..0000000 --- a/lib/core/theme/app_theme.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:doormile/core/theme/app_colors.dart'; - -/// Central theme + reusable text styles based on the Doormile design system. -class AppTheme { - AppTheme._(); - - static const fontFamily = 'Public Sans'; - - static ThemeData get light { - final base = ThemeData.light(useMaterial3: true); - - return base.copyWith( - scaffoldBackgroundColor: AppColors.surface, - colorScheme: const ColorScheme.light( - primary: AppColors.primary, - onPrimary: AppColors.onPrimary, - primaryContainer: AppColors.primaryContainer, - onPrimaryContainer: AppColors.onPrimaryContainer, - secondary: AppColors.secondary, - onSecondary: Colors.white, - tertiary: AppColors.tertiary, - onTertiary: Colors.white, - error: AppColors.error, - surface: AppColors.surface, - onSurface: AppColors.onSurface, - surfaceContainerHighest: AppColors.surfaceContainerHighest, - outline: AppColors.outline, - outlineVariant: AppColors.outlineVariant, - ), - textTheme: base.textTheme.apply( - fontFamily: fontFamily, - bodyColor: AppColors.onSurface, - displayColor: AppColors.onSurface, - ), - appBarTheme: const AppBarTheme( - backgroundColor: Colors.transparent, - elevation: 0, - scrolledUnderElevation: 0, - foregroundColor: AppColors.primary, - centerTitle: false, - ), - inputDecorationTheme: InputDecorationTheme( - filled: true, - fillColor: AppColors.surfaceContainerLowest, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15), - hintStyle: bodyMd.copyWith(color: AppColors.outline), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: AppColors.outlineVariant), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: AppColors.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: AppColors.primary, width: 1.4), - ), - ), - splashFactory: InkRipple.splashFactory, - ); - } - - static const _f = fontFamily; - - /// Builds a style backed by the variable font's true `wght` axis so weights - /// render at their real glyph widths (no synthetic-bold widening). - static TextStyle _ts(double size, int wght, double lineHeight, [Color? color, double? letterSpacing]) { - return TextStyle( - fontFamily: _f, - fontSize: size, - height: lineHeight / size, - letterSpacing: letterSpacing, - color: color ?? AppColors.onSurface, - fontWeight: FontWeight.values[(wght ~/ 100) - 1], - fontVariations: [FontVariation('wght', wght.toDouble())], - ); - } - - // ---- Typography helpers (mirror DESIGN.md scale) ---- - static final TextStyle headlineLg = _ts(22, 800, 28, null, -0.5); - static final TextStyle headlineMd = _ts(17, 700, 24, null, -0.3); - static final TextStyle headlineSm = _ts(15, 700, 22, null, -0.2); - static final TextStyle bodyLg = _ts(15, 400, 22); - static final TextStyle bodyMd = _ts(13, 400, 20); - static final TextStyle labelMd = _ts(13, 700, 20); - static final TextStyle caption = _ts(11, 500, 16, AppColors.onSurfaceVariant); - static final TextStyle button = _ts(14, 700, 20, null, 0.2); - - /// Ambient card shadow (Level 1 elevation from the design brief). - static List get cardShadow => const [ - BoxShadow(color: Color(0x12000000), blurRadius: 28, offset: Offset(0, 12)), - BoxShadow(color: Color(0x08C31723), blurRadius: 8, offset: Offset(0, 2)), - ]; -} diff --git a/lib/core/utils/snackbar_utils.dart b/lib/core/utils/snackbar_utils.dart deleted file mode 100644 index b845c05..0000000 --- a/lib/core/utils/snackbar_utils.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; - -class SnackbarUtils { - static void showError(BuildContext context, String rawError) { - String message = _getProfessionalErrorMessage(rawError); - - final snackBar = SnackBar( - content: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon(Icons.error_outline_rounded, color: Colors.white, size: 24), - const SizedBox(width: 12), - Expanded( - child: Text( - message, - style: AppTheme.bodyMd.copyWith(color: Colors.white, fontWeight: FontWeight.w500), - ), - ), - ], - ), - backgroundColor: AppColors.error, - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - elevation: 4, - duration: const Duration(seconds: 4), - ); - - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(snackBar); - } - - static void showSuccess(BuildContext context, String message) { - final snackBar = SnackBar( - content: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon(Icons.check_circle_outline_rounded, color: Colors.white, size: 24), - const SizedBox(width: 12), - Expanded( - child: Text( - message, - style: AppTheme.bodyMd.copyWith(color: Colors.white, fontWeight: FontWeight.w500), - ), - ), - ], - ), - backgroundColor: const Color(0xFF4CAF50), // Green success color - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - elevation: 4, - duration: const Duration(seconds: 3), - ); - - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(snackBar); - } - - static String _getProfessionalErrorMessage(String raw) { - final lowerRaw = raw.toLowerCase(); - - // Check for network errors - if (lowerRaw.contains('socketexception') || - lowerRaw.contains('connection refused') || - lowerRaw.contains('failed host lookup') || - lowerRaw.contains('network') || - lowerRaw.contains('timeout')) { - return 'Please check your internet connection and try again.'; - } - - // Common auth and business errors - if (lowerRaw.contains('invalid otp') || lowerRaw.contains('incorrect otp')) { - return 'The OTP you entered is incorrect. Please try again.'; - } - if (lowerRaw.contains('user not found') || lowerRaw.contains('not registered')) { - return 'Account not found. Please check your details.'; - } - if (lowerRaw.contains('already registered') || lowerRaw.contains('exists')) { - return 'This phone number is already registered.'; - } - if (lowerRaw.contains('pin')) { - return 'Incorrect PIN. Please try again.'; - } - if (lowerRaw.contains('unauthorized') || lowerRaw.contains('expired')) { - return 'Your session has expired. Please log in again.'; - } - - // Default generic server / unknown error - return 'Something went wrong. Please try again later.'; - } -} diff --git a/lib/core/widgets/common.dart b/lib/core/widgets/common.dart deleted file mode 100644 index c9470c2..0000000 --- a/lib/core/widgets/common.dart +++ /dev/null @@ -1,434 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; - -/// White rounded card with the design-system ambient shadow. -class AppCard extends StatelessWidget { - final Widget child; - final EdgeInsetsGeometry padding; - final VoidCallback? onTap; - final Color color; - final BorderRadius? radius; - final Border? border; - - const AppCard({ - super.key, - required this.child, - this.padding = const EdgeInsets.all(16), - this.onTap, - this.color = AppColors.surfaceContainerLowest, - this.radius, - this.border, - }); - - @override - Widget build(BuildContext context) { - final br = radius ?? BorderRadius.circular(18); - return Material( - color: color, - borderRadius: br, - child: InkWell( - onTap: onTap, - borderRadius: br, - child: Ink( - decoration: BoxDecoration( - color: color, - borderRadius: br, - border: border ?? Border.all(color: AppColors.surfaceContainer.withValues(alpha: 0.7)), - boxShadow: AppTheme.cardShadow, - ), - child: Padding(padding: padding, child: child), - ), - ), - ); - } -} - -/// Primary filled action button (min height 52, 12px radius). -class PrimaryButton extends StatelessWidget { - final String label; - final VoidCallback? onPressed; - final IconData? trailingIcon; - final bool loading; - final Color color; - final double height; - - const PrimaryButton({ - super.key, - required this.label, - required this.onPressed, - this.trailingIcon, - this.loading = false, - this.color = AppColors.primary, - this.height = 52, - }); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: double.infinity, - height: height, - child: ElevatedButton( - onPressed: loading ? null : onPressed, - style: ElevatedButton.styleFrom( - backgroundColor: color, - foregroundColor: Colors.white, - disabledBackgroundColor: color.withValues(alpha: 0.6), - disabledForegroundColor: Colors.white, - elevation: 0, - shadowColor: AppColors.primary.withValues(alpha: 0.2), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - child: loading - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator(strokeWidth: 2.4, color: Colors.white), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text(label, - style: AppTheme.button, - overflow: TextOverflow.ellipsis, - maxLines: 1), - ), - if (trailingIcon != null) ...[ - const SizedBox(width: 8), - Icon(trailingIcon, size: 20), - ], - ], - ), - ), - ); - } -} - -/// Secondary tinted button (pink bg, red text). -class SecondaryButton extends StatelessWidget { - final String label; - final VoidCallback? onPressed; - const SecondaryButton({super.key, required this.label, this.onPressed}); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 44, - child: OutlinedButton( - onPressed: onPressed, - style: OutlinedButton.styleFrom( - backgroundColor: AppColors.surfaceContainerLowest, - foregroundColor: AppColors.primary, - side: const BorderSide(color: AppColors.outlineVariant), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - padding: const EdgeInsets.symmetric(horizontal: 18), - ), - child: Text(label, style: AppTheme.labelMd.copyWith(color: AppColors.primary)), - ), - ); - } -} - -/// Pill-shaped status / category chip. -class StatusChip extends StatelessWidget { - final String label; - final Color color; - final Color bg; - final IconData? icon; - const StatusChip({ - super.key, - required this.label, - required this.color, - required this.bg, - this.icon, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(999), - border: Border.all(color: color.withValues(alpha: 0.12)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[Icon(icon, size: 14, color: color), const SizedBox(width: 4)], - Text(label, - style: AppTheme.caption.copyWith(color: color, fontWeight: FontWeight.w600)), - ], - ), - ); - } -} - -/// A horizontal stepper progress bar with a fraction filled. -class JourneyProgressBar extends StatelessWidget { - final double value; // 0..1 - final bool gradient; - const JourneyProgressBar({super.key, required this.value, this.gradient = false}); - - @override - Widget build(BuildContext context) { - return ClipRRect( - borderRadius: BorderRadius.circular(999), - child: Stack( - children: [ - Container(height: 6, color: AppColors.surfaceContainer), - FractionallySizedBox( - widthFactor: value.clamp(0, 1), - child: Container( - height: 6, - decoration: BoxDecoration( - color: gradient ? null : AppColors.primary, - gradient: gradient - ? const LinearGradient(colors: [ - AppColors.firstMile, - AppColors.midMile, - AppColors.lastMile, - ]) - : null, - ), - ), - ), - ], - ), - ); - } -} - -/// Eco / EV reassurance banner used across screens. -class EcoBanner extends StatelessWidget { - final String text; - const EcoBanner({super.key, required this.text}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppColors.tertiaryFixed.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.tertiary.withValues(alpha: 0.1)), - ), - child: Row( - children: [ - const Icon(Icons.eco, color: AppColors.tertiary, size: 22), - const SizedBox(width: 12), - Expanded( - child: Text(text, - style: AppTheme.bodyMd.copyWith(color: AppColors.tertiary, height: 1.35)), - ), - ], - ), - ); - } -} - -/// Warm page backdrop with restrained route/map texture. -class AppBackground extends StatelessWidget { - final Widget child; - final EdgeInsetsGeometry padding; - const AppBackground({ - super.key, - required this.child, - this.padding = EdgeInsets.zero, - }); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFFFFFBF8), AppColors.surface], - ), - ), - child: Stack( - children: [ - const Positioned( - top: 240, - left: -70, - child: _SoftCircle(size: 180, color: Color(0x1A126E82)), - ), - Padding(padding: padding, child: child), - ], - ), - ); - } -} - -class _SoftCircle extends StatelessWidget { - final double size; - final Color color; - const _SoftCircle({required this.size, required this.color}); - - @override - Widget build(BuildContext context) { - return Container( - width: size, - height: size, - decoration: BoxDecoration(shape: BoxShape.circle, color: color), - ); - } -} - -/// Reusable immersive red header used by primary tabs. -class ImmersiveHeader extends StatelessWidget { - final String title; - final String subtitle; - final IconData icon; - final Widget? trailing; - final bool showBack; - - const ImmersiveHeader({ - super.key, - required this.title, - required this.subtitle, - required this.icon, - this.trailing, - this.showBack = false, - }); - - @override - Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [AppColors.primaryContainer, AppColors.primary], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.vertical(bottom: Radius.circular(24)), - ), - child: ClipRRect( - borderRadius: const BorderRadius.vertical(bottom: Radius.circular(24)), - child: AnnotatedRegion( - value: SystemUiOverlayStyle.light, - child: Stack( - children: [ - Positioned( - right: -20, - bottom: -30, - child: Icon(icon, size: 140, color: Colors.white.withValues(alpha: 0.04)), - ), - Positioned( - right: 100, - top: -10, - child: Transform.rotate( - angle: 0.2, - child: Icon(Icons.auto_awesome_rounded, size: 60, color: Colors.white.withValues(alpha: 0.03)), - ), - ), - SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), - child: Row( - children: [ - if (showBack) ...[ - IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.arrow_back, color: Colors.white), - padding: EdgeInsets.zero, - alignment: Alignment.centerLeft, - ), - const SizedBox(width: 4), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTheme.headlineLg.copyWith(color: Colors.white)), - const SizedBox(height: 4), - Text(subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTheme.bodyMd.copyWith(color: Colors.white.withValues(alpha: 0.8))), - ], - ), - ), - if (trailing != null) trailing!, - ], - ), - ), - ), - ], - ), - ), - ), - ); - } -} - -/// A small circular icon avatar with a tinted background. -class IconBadge extends StatelessWidget { - final IconData icon; - final Color bg; - final Color color; - final double size; - final double iconSize; - final bool circle; - const IconBadge({ - super.key, - required this.icon, - required this.bg, - required this.color, - this.size = 48, - this.iconSize = 22, - this.circle = false, - }); - - @override - Widget build(BuildContext context) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(circle ? size : 12), - ), - child: Icon(icon, color: color, size: iconSize), - ); - } -} - -/// Title row with an optional trailing action ("View All"). -class SectionHeader extends StatelessWidget { - final String title; - final String? action; - final VoidCallback? onAction; - const SectionHeader({super.key, required this.title, this.action, this.onAction}); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Text(title, - style: AppTheme.headlineSm, overflow: TextOverflow.ellipsis, maxLines: 1), - ), - if (action != null) - GestureDetector( - onTap: onAction, - child: Padding( - padding: const EdgeInsets.only(left: 8), - child: Text(action!, - style: AppTheme.labelMd.copyWith(color: AppColors.primary)), - ), - ), - ], - ); - } -} diff --git a/lib/core/widgets/logo.dart b/lib/core/widgets/logo.dart deleted file mode 100644 index ef1ac09..0000000 --- a/lib/core/widgets/logo.dart +++ /dev/null @@ -1,69 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; - -/// Stylised Doormile badge mark — a rounded pin holding a package glyph. -class DoormileMark extends StatelessWidget { - final double size; - final Color color; - final Color glyphColor; - const DoormileMark({ - super.key, - this.size = 48, - this.color = AppColors.primary, - this.glyphColor = Colors.white, - }); - - @override - Widget build(BuildContext context) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(size * 0.28), - boxShadow: [ - BoxShadow( - color: color.withValues(alpha: 0.25), - blurRadius: size * 0.3, - offset: Offset(0, size * 0.12), - ), - ], - ), - child: Icon(Icons.local_shipping_rounded, color: glyphColor, size: size * 0.55), - ); - } -} - -/// Full wordmark: badge + "Doormile" text. -class DoormileWordmark extends StatelessWidget { - final double markSize; - final double fontSize; - final Color textColor; - final Color markColor; - final Color glyphColor; - const DoormileWordmark({ - super.key, - this.markSize = 40, - this.fontSize = 24, - this.textColor = AppColors.primary, - this.markColor = AppColors.primary, - this.glyphColor = Colors.white, - }); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - DoormileMark(size: markSize, color: markColor, glyphColor: glyphColor), - const SizedBox(width: 10), - Text( - 'Doormile', - style: AppTheme.headlineLg.copyWith(color: textColor, fontSize: fontSize), - ), - ], - ); - } -} diff --git a/lib/data/api_client.dart b/lib/data/api_client.dart new file mode 100644 index 0000000..7fa1e1b --- /dev/null +++ b/lib/data/api_client.dart @@ -0,0 +1,476 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import 'api_exception.dart'; +import 'app_config.dart'; +import 'session_store.dart'; + +/// One decoded response: the `data` payload plus the envelope's metadata. +class ApiResponse { + const ApiResponse({ + this.data, + this.etag, + this.total, + this.nextCursor, + this.notModified = false, + this.requestId, + }); + + /// The `data` member of the envelope. `null` on a 304 or a 204. + final dynamic data; + + /// `ETag` from the response, to be replayed as `If-None-Match`. + final String? etag; + + /// Total rows behind a filtered list, when the endpoint sends one. + final int? total; + + /// Keyset cursor for the next page. Null on the last page. + final String? nextCursor; + + /// True when the server answered `304` — the caller keeps what it has. + final bool notModified; + + final String? requestId; + + Map get map => + data is Map ? data as Map : const {}; + + /// The rows of a list endpoint. + /// + /// `data` is the list itself on most of this surface, but a paged endpoint + /// wraps it — `data: { items: [...], nextCursor }` — and the contract does + /// not say which of the two spellings a given route uses. Reading through the + /// wrapper costs one lookup; guessing wrong shows the customer an empty + /// Orders tab and no error at all, which is the failure that cannot be + /// diagnosed from a screenshot. + List> get rows => + (_list ?? const []).whereType>().toList(); + + List? get _list { + final payload = data; + if (payload is List) return payload; + if (payload is Map) { + for (final key in _listKeys) { + final value = payload[key]; + if (value is List) return value; + } + } + return null; + } + + static const List _listKeys = [ + 'items', + 'results', + 'rows', + 'data', + 'bookings', + 'orders', + 'states', + 'districts', + 'slots', + 'places', + 'locations', + ]; +} + +/// The app's only HTTP caller. +/// +/// Everything the contract asks for on the wire lives here and nowhere else: +/// the `data` envelope, the nine error codes, bearer auth with silent refresh, +/// the client identity headers, idempotency keys, `ETag` revalidation, keyset +/// paging and the retry policy. A screen that wanted to add a header would have +/// to come through this class, which is the point. +class ApiClient { + ApiClient({ + http.Client? httpClient, + SessionStore? sessions, + this.onSessionLost, + }) : _http = httpClient ?? http.Client(), + _sessions = sessions ?? SecureSessionStore(); + + final http.Client _http; + final SessionStore _sessions; + + /// Called when a refresh fails and the session is dropped, so [AppState] can + /// return the customer to sign-in rather than leaving them on a dead screen. + /// Settable because the state that handles it is built after this client. + void Function()? onSessionLost; + + SessionStore get sessions => _sessions; + + Session? _session; + bool _restored = false; + + /// Single-flight refresh: ten calls hitting 401 together must produce one + /// refresh, not ten — the token rotates, so the other nine would be replaying + /// a token the server has already revoked and would kill the whole chain. + Future? _refreshing; + + final Random _rng = Random(); + + // ------------------------------------------------------------------ session + + Future currentSession() async { + if (!_restored) { + _session = await _sessions.read(); + _restored = true; + } + return _session; + } + + Future adoptSession(Session session) async { + _session = session; + _restored = true; + await _sessions.write(session); + } + + Future dropSession() async { + _session = null; + _restored = true; + await _sessions.clear(); + } + + // ------------------------------------------------------------------ requests + + Future get( + String path, { + Map? query, + String? ifNoneMatch, + bool authenticated = true, + Duration? timeout, + }) => _send( + 'GET', + path, + query: query, + ifNoneMatch: ifNoneMatch, + authenticated: authenticated, + timeout: timeout, + // A GET carries no side effect, so replaying one is always safe. + retries: 2, + ); + + Future post( + String path, { + Object? body, + Map? query, + String? idempotencyKey, + bool authenticated = true, + Duration? timeout, + }) => _send( + 'POST', + path, + body: body, + query: query, + idempotencyKey: idempotencyKey, + authenticated: authenticated, + timeout: timeout ?? AppConfig.writeTimeout, + ); + + Future patch( + String path, { + Object? body, + bool authenticated = true, + }) => _send('PATCH', path, body: body, authenticated: authenticated); + + Future put( + String path, { + Object? body, + bool authenticated = true, + }) => _send('PUT', path, body: body, authenticated: authenticated); + + Future delete(String path, {bool authenticated = true}) => + _send('DELETE', path, authenticated: authenticated); + + /// A fresh idempotency key. Held by the caller across retries of the *same* + /// intent — a new key means a new booking. + String newIdempotencyKey() { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; + final now = DateTime.now().microsecondsSinceEpoch.toRadixString(36); + final salt = List.generate( + 10, + (_) => alphabet[_rng.nextInt(alphabet.length)], + ).join(); + return '$now-$salt'; + } + + // ------------------------------------------------------------------ internals + + Future _send( + String method, + String path, { + Object? body, + Map? query, + String? ifNoneMatch, + String? idempotencyKey, + bool authenticated = true, + Duration? timeout, + int retries = 0, + bool allowRefresh = true, + }) async { + final uri = Uri.parse( + AppConfig.url(path), + ).replace(queryParameters: (query?.isEmpty ?? true) ? null : query); + + ApiException? lastTransient; + + for (var attempt = 0; attempt <= retries; attempt++) { + if (attempt > 0) { + await Future.delayed(Duration(milliseconds: 300 * attempt * attempt)); + } + try { + final response = await _once( + method, + uri, + body: body, + ifNoneMatch: ifNoneMatch, + idempotencyKey: idempotencyKey, + authenticated: authenticated, + timeout: timeout, + ); + return response; + } on ApiException catch (e) { + // 401 once, then refresh and try again — but never on the refresh call + // itself, which would recurse. + if (e.isAuthFailure && authenticated && allowRefresh) { + final refreshed = await _refreshSession(); + if (refreshed != null) { + return _send( + method, + path, + body: body, + query: query, + ifNoneMatch: ifNoneMatch, + idempotencyKey: idempotencyKey, + authenticated: authenticated, + timeout: timeout, + retries: 0, + allowRefresh: false, + ); + } + await dropSession(); + onSessionLost?.call(); + rethrow; + } + if (!e.isTransient || attempt == retries) rethrow; + lastTransient = e; + } + } + throw lastTransient ?? + ApiException(ApiException.network, 'We could not reach Doormile'); + } + + Future _once( + String method, + Uri uri, { + Object? body, + String? ifNoneMatch, + String? idempotencyKey, + required bool authenticated, + Duration? timeout, + }) async { + final headers = { + 'Accept': 'application/json', + 'X-Client': AppConfig.clientHeader, + 'X-Platform': AppConfig.platformHeader, + if (body != null) 'Content-Type': 'application/json', + 'If-None-Match': ?ifNoneMatch, + 'Idempotency-Key': ?idempotencyKey, + }; + + if (authenticated) { + final session = await currentSession(); + if (session != null) { + // Refresh proactively rather than spending a 401 to discover it. + final usable = session.isExpired + ? (await _refreshSession() ?? session) + : session; + headers['Authorization'] = 'Bearer ${usable.accessToken}'; + } + } + + final request = http.Request(method, uri)..headers.addAll(headers); + if (body != null) request.body = jsonEncode(body); + + http.Response response; + try { + final streamed = await _http + .send(request) + .timeout(timeout ?? AppConfig.requestTimeout); + response = await http.Response.fromStream(streamed); + } on TimeoutException { + throw ApiException( + ApiException.network, + 'Doormile took too long to answer', + ); + } on SocketException { + throw ApiException(ApiException.network, 'We could not reach Doormile'); + } on http.ClientException { + throw ApiException(ApiException.network, 'We could not reach Doormile'); + } + + return _decode(response, uri); + } + + ApiResponse _decode(http.Response response, Uri uri) { + final requestId = response.headers['x-request-id']; + final status = response.statusCode; + + if (status == 304) { + return ApiResponse( + notModified: true, + etag: response.headers['etag'], + requestId: requestId, + ); + } + + dynamic decoded; + if (response.body.isNotEmpty) { + try { + decoded = jsonDecode(response.body); + } catch (_) { + // An HTML error page or a truncated body. The contract forbids both, + // so treat it as the server being unreachable rather than guessing. + _log(uri, status, requestId, 'unparseable body'); + throw ApiException( + status >= 500 ? ApiException.serverError : ApiException.network, + 'Something went wrong', + status, + requestId, + ); + } + } + + final envelope = decoded is Map ? decoded : const {}; + + if (status >= 200 && status < 300) { + // `success: false` with a 200 would be a contract violation, but reading + // it costs nothing and beats rendering a failure as data. + if (envelope['success'] == false) { + throw _errorFrom(envelope, status, requestId, uri); + } + final payload = envelope.containsKey('data') ? envelope['data'] : decoded; + // Keyset metadata sits beside `data` on some routes and inside it on the + // paged ones. Both are read, envelope first. + final inner = payload is Map + ? payload + : const {}; + return ApiResponse( + data: payload, + etag: response.headers['etag'], + total: (envelope['total'] ?? inner['total']) is num + ? ((envelope['total'] ?? inner['total']) as num).toInt() + : null, + nextCursor: + (envelope['nextCursor'] ?? inner['nextCursor']) as String?, + requestId: requestId, + ); + } + + throw _errorFrom(envelope, status, requestId, uri); + } + + ApiException _errorFrom( + Map envelope, + int status, + String? requestId, + Uri uri, + ) { + final error = envelope['error']; + final serverCode = (error is Map && error['code'] is String) + ? error['code'] as String + : ''; + final code = ApiException.normalise( + serverCode, + () => _codeForStatus(status), + ); + final message = envelope['message'] is String && + (envelope['message'] as String).trim().isNotEmpty + ? envelope['message'] as String + : (error is Map && error['message'] is String + ? error['message'] as String + : _messageForStatus(status)); + _log(uri, status, requestId, serverCode.isEmpty ? code : serverCode); + return ApiException(code, message, status, requestId, serverCode); + } + + static String _codeForStatus(int status) => switch (status) { + 400 => ApiException.invalid, + 401 => ApiException.unauthorized, + 403 => ApiException.forbidden, + 404 => ApiException.notFound, + 409 => ApiException.conflict, + 422 => ApiException.unserviceable, + 429 => ApiException.rateLimited, + _ => status >= 500 ? ApiException.serverError : ApiException.invalid, + }; + + static String _messageForStatus(int status) => switch (status) { + 401 => 'Please sign in again', + 403 => 'You do not have access to this', + 404 => 'We could not find that', + 409 => 'That is no longer available', + 429 => 'Too many attempts. Try again in a minute', + _ => 'Something went wrong', + }; + + void _log(Uri uri, int status, String? requestId, String detail) { + debugPrint( + '[API] $status ${uri.path} · $detail' + '${requestId == null ? '' : ' · req $requestId'}', + ); + } + + // ------------------------------------------------------------------ refresh + + /// Rotates the session. Returns null when the refresh itself failed, which + /// means the chain is dead and the customer must sign in again. + Future _refreshSession() { + final inFlight = _refreshing; + if (inFlight != null) return inFlight; + + final future = _doRefresh(); + _refreshing = future; + return future.whenComplete(() => _refreshing = null); + } + + Future _doRefresh() async { + final session = await currentSession(); + if (session == null) return null; + try { + final response = await _send( + 'POST', + '/auth/refresh', + body: {'refreshToken': session.refreshToken}, + authenticated: false, + allowRefresh: false, + ); + final data = response.map; + final access = data['accessToken'] as String?; + if (access == null || access.isEmpty) return null; + final rotated = Session( + accessToken: access, + // A server that does not rotate returns no new refresh token; keeping + // the old one is then correct. If it does rotate, this is the only + // copy that still works, so it is persisted before anything uses it. + refreshToken: (data['refreshToken'] as String?) ?? session.refreshToken, + expiresAt: DateTime.now().add( + Duration(seconds: (data['expiresIn'] as num?)?.toInt() ?? 3600), + ), + customer: session.customer, + ); + await adoptSession(rotated); + return rotated; + } on ApiException catch (e) { + debugPrint('[API] refresh failed: $e'); + return null; + } + } + + void close() => _http.close(); +} diff --git a/lib/data/api_exception.dart b/lib/data/api_exception.dart new file mode 100644 index 0000000..d580e09 --- /dev/null +++ b/lib/data/api_exception.dart @@ -0,0 +1,106 @@ +/// Thrown for every failed call so the UI can show one consistent error state. +/// +/// [code] is the contract's machine-readable key and is the **only** thing +/// callers may branch on. [message] is customer-safe English written by the +/// server and is rendered verbatim — never parsed. +class ApiException implements Exception { + ApiException( + this.code, [ + String? message, + this.status, + this.requestId, + String? serverCode, + ]) : message = message ?? 'Something went wrong', + serverCode = serverCode ?? code; + + final String code; + final String message; + + /// Exactly what the server put in `error.code` — `SLOT_CAPACITY_FULL`, + /// `UNSERVICEABLE_PINCODE`, and so on. [code] is that value translated into + /// the vocabulary below; this one is kept for the log line and for a support + /// report, and nothing branches on it. + final String serverCode; + + /// HTTP status, when there was one. Null for a transport failure. + final int? status; + + /// The server's `X-Request-Id`, so a support report can be correlated. + final String? requestId; + + /// The client vocabulary: the contract's codes translated by [normalise], + /// plus the ones the client raises itself. + static const String network = 'network'; + static const String invalid = 'invalid'; + static const String invalidName = 'invalid_name'; + static const String invalidOtp = 'invalid_otp'; + static const String unauthorized = 'unauthorized'; + static const String forbidden = 'forbidden'; + static const String notFound = 'not_found'; + static const String conflict = 'conflict'; + static const String unserviceable = 'unserviceable'; + static const String rateLimited = 'rate_limited'; + static const String serverError = 'server_error'; + + /// The slot is gone — filled up, or its window passed. Its own code because + /// the recovery is specific: re-read the slots and go back to picking one. + static const String slotUnavailable = 'slot_unavailable'; + + /// The Miler has already arrived, so the cancel window has closed. + static const String notCancellable = 'not_cancellable'; + + /// Translates the contract's `error.code` into the vocabulary above. + /// + /// The wire spells its codes in capitals; the client's are lowercase, and + /// mapping them is not cosmetic — an untranslated `UNAUTHORIZED` does not + /// satisfy [isAuthFailure], which is what triggers the token refresh, so a + /// signed-in customer would have been dropped at the first expired token + /// instead of silently getting a new one. + /// + /// A code that is already lowercase is passed through untouched, and an + /// unrecognised one falls back to whatever the HTTP status means. + static String normalise(String raw, String Function() fromStatus) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) return fromStatus(); + if (trimmed != trimmed.toUpperCase()) return trimmed; + return switch (trimmed) { + 'UNAUTHORIZED' || 'TOKEN_EXPIRED' => unauthorized, + 'FORBIDDEN' => forbidden, + 'NOT_FOUND' => notFound, + 'INVALID_INPUT' || 'VALIDATION_ERROR' => invalid, + 'SLOT_UNAVAILABLE' || 'SLOT_EXPIRED' || 'SLOT_CAPACITY_FULL' => + slotUnavailable, + 'BOOKING_NOT_CANCELLABLE' => notCancellable, + 'UNSERVICEABLE_PINCODE' => unserviceable, + 'RATE_LIMITED' => rateLimited, + 'INTERNAL_ERROR' => serverError, + _ => fromStatus(), + }; + } + + /// True when retrying the identical request could plausibly succeed. + bool get isTransient => + code == network || code == serverError || code == rateLimited; + + /// The session is gone; the customer has to sign in again. + bool get isAuthFailure => code == unauthorized; + + /// The slot the customer picked is no longer bookable — either it filled up + /// or its window has passed. Both recover the same way: re-fetch the slots + /// and put them back on slot selection. + /// + /// The server distinguishes them (`409 conflict` for a genuine capacity race, + /// `400 invalid` for a window that has passed) but the customer's next action + /// is identical, so the client does not. + bool get needsFreshSlots => + code == slotUnavailable || + code == conflict || + (code == invalid && message.toLowerCase().contains('pickup')); + + @override + String toString() => + 'ApiException($code' + '${serverCode == code ? '' : '/$serverCode'}' + '${status == null ? '' : ' $status'}): $message' + '${requestId == null ? '' : ' [req $requestId]'}'; +} diff --git a/lib/data/app_config.dart b/lib/data/app_config.dart new file mode 100644 index 0000000..bfcf4e3 --- /dev/null +++ b/lib/data/app_config.dart @@ -0,0 +1,278 @@ +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; + +/// Which backend this build talks to. +/// +/// Set at compile time: `flutter run --dart-define=DM_ENV=prod`. +enum DoormileEnvironment { dev, staging, prod } + +/// Build-time configuration: which backend this build talks to, and the +/// identity it sends on every request. +/// +/// ── The one bypass, and why it is not the Miler app's bypass ── +/// +/// The Miler app shipped a `USE_NEW_API=false` flag that silently swapped +/// authentication for a bypass — any four digits logged you in as a fake rider, +/// with invented earnings, in a build that could ship. +/// +/// This app has one API implementation that talks to a server, and exactly one +/// exception to it: [useDevData], now the **default in a debug build** because +/// staging has no SMS gateway and the login screen cannot otherwise be walked. +/// Three things keep it from becoming the flag that was deleted — it cannot +/// exist in a release build (`!kReleaseMode`, not a define), it is named on the +/// Account screen every time it is on, and nothing it does reaches a server. +class AppConfig { + AppConfig._(); + + // ---------------------------------------------------------------- environment + + static const String _envName = String.fromEnvironment( + 'DM_ENV', + defaultValue: 'staging', + ); + + static DoormileEnvironment get environment => switch (_envName) { + 'prod' || 'production' => DoormileEnvironment.prod, + 'dev' || 'development' => DoormileEnvironment.dev, + _ => DoormileEnvironment.staging, + }; + + static bool get isProd => environment == DoormileEnvironment.prod; + + // ------------------------------------------------------------------- base URL + + /// Overrides the per-environment default. The staging host is not yet + /// confirmed by the backend team, so staging builds are expected to pass + /// `--dart-define=DM_API_BASE=https:///api/v1`. + static const String _baseOverride = String.fromEnvironment('DM_API_BASE'); + + static const String _prodBase = 'https://api.doormile.com/api/v1'; + + /// Placeholder until the backend team names the staging host. A staging build + /// without `DM_API_BASE` therefore talks to production, which is wrong but + /// loud — it is better than silently talking to a host that does not exist. + static const String _stagingBase = _prodBase; + + static String get baseUrl { + if (_baseOverride.isNotEmpty) return _stripTrailingSlash(_baseOverride); + return switch (environment) { + DoormileEnvironment.prod => _prodBase, + DoormileEnvironment.staging => _stagingBase, + DoormileEnvironment.dev => _stripTrailingSlash( + const String.fromEnvironment( + 'DM_API_BASE_DEV', + defaultValue: 'http://10.0.2.2:8080/api/v1', + ), + ), + }; + } + + static String _stripTrailingSlash(String s) => + s.endsWith('/') ? s.substring(0, s.length - 1) : s; + + /// Everything in this document lives under one namespace. + static String url(String path) => + '$baseUrl/customer${path.startsWith('/') ? path : '/$path'}'; + + // ---------------------------------------------------------- development data + // + // ── The one exception to "no invented data", and it is now the debug default ── + // + // The shipping app has one API implementation and talks to a server; a build + // that cannot reach one shows its error state rather than inventing a + // booking, a slot or a price. [useDevData] is the single, loud exception. + // + // **Off by default again.** It was briefly the debug default so the app could + // be opened at all, and the cost showed up immediately: a booking made in + // that mode reaches no server and never appears in the admin console, which + // looks exactly like a broken integration. Skipping the login screen is worth + // a flag; faking every booking behind it is not. + // + // For a session without typing a code, use [autoLoginIdentifier] instead — + // it signs in against the **real** API, so everything after it is real. + // + // --dart-define=DM_MOCK=true offline fake, for UI work with no server + // --dart-define=DM_DEV_LOGIN=false keep the fake, but walk the entrance + // + // The guard has not moved: `!kReleaseMode`, so no define puts this in a + // release build. When it is on the app talks to [DevDoormileApi] instead of + // the network, `9876543210` + any 4-digit code signs in, and the Account + // screen says `DEV DATA (offline)`. **Nothing it does reaches a server or the + // admin console** — a booking made in this mode is not a booking. + static const bool _devDataRequested = bool.fromEnvironment( + 'DM_MOCK', + defaultValue: false, + ); + + /// True under `flutter test`, where the widget tests drive the whole journey + /// against [DevDoormileApi] and there is no server to reach. + static bool get isTest { + if (kIsWeb) return false; + try { + return Platform.environment.containsKey('FLUTTER_TEST'); + } catch (_) { + return false; + } + } + + /// True only when dev data was asked for — or we are under test — **and** + /// this is not a release build. There is no define that can turn it on in a + /// release. + static bool get useDevData => (_devDataRequested || isTest) && !kReleaseMode; + + /// Opens the app already signed in, skipping the login screen. + /// + /// On by default wherever [useDevData] is, which is the point of the pair: + /// one `flutter run` and you are looking at Home. Only meaningful alongside + /// dev data — there is no one to sign in as without it, so a build talking to + /// the real API still signs in properly. + /// + /// To walk the entrance itself — the sign-in, sign-up and verify screens — + /// turn this off and keep the dev data: `--dart-define=DM_DEV_LOGIN=false`, + /// where `9876543210` and any 4-digit code gets you in. + /// + /// Never under `flutter test`: the widget tests drive the real entrance — + /// phone, code, and the guard that refuses to open the code screen when no + /// code was sent — and a session waiting for them at launch would delete + /// that coverage silently. + static bool get devAutoLogin => + useDevData && + !isTest && + const bool.fromEnvironment('DM_DEV_LOGIN', defaultValue: true); + + // --------------------------------------------------------------- auto sign-in + // + // ── Skipping the login SCREEN without faking the login ── + // + // This is the flag to reach for when you want the app to open on Home and + // still be a real customer: at launch, with no stored session, the app runs + // the actual `POST /customer/auth/otp/request` + `/verify` pair for you. The + // token it gets back is the server's, every call after it is authorised + // normally, and a booking made in this build lands in `pickupbookings` and + // shows up in the admin console. + // + // --dart-define=DM_LOGIN_AS=9876543210 --dart-define=DM_LOGIN_CODE=1234 + // + // The code has to be one the server will accept. Two ways to have one: + // set `CX_STAGING_OTP` on a non-production backend (a fixed code, refused + // outright when ENV=production), or read the code the backend logged — with + // no SMS gateway registered its `logSender` writes every code to the + // application log and reports the send as successful. + // + // It only has to work once per install: the session is persisted, so later + // launches restore it and these defines stop mattering. + // + // `!kReleaseMode`, like everything else here, and named on the Account + // screen whenever it is on. + + /// The phone number or email to sign in as. + static String get autoLoginIdentifier => + kReleaseMode ? '' : const String.fromEnvironment('DM_LOGIN_AS'); + + /// The verification code that identifier will accept. + static String get autoLoginCode => + kReleaseMode ? '' : const String.fromEnvironment('DM_LOGIN_CODE'); + + static bool get hasAutoLogin => + autoLoginIdentifier.isNotEmpty && autoLoginCode.isNotEmpty; + + // ------------------------------------------------------------- ops QA only + + /// Mirrors the backend's own `CX_ALLOW_STAGE_OVERRIDE`: the QA helper at + /// `POST /customer/ops/bookings/{reference}/stage` exists only off + /// production, and only when ops has turned it on. + /// + /// Turning it on here does not create the endpoint — a build that asks for + /// it against a server that has it disabled gets a refusal, which is the + /// right outcome. It only decides whether the control is offered. + static bool get allowStageOverride => + !kReleaseMode && + !isProd && + const bool.fromEnvironment( + 'DM_ALLOW_STAGE_OVERRIDE', + defaultValue: false, + ); + + /// Whether the tracking screen offers its stage stepper. On a dev build it + /// walks [DevDoormileApi]'s in-memory booking; on staging it drives the real + /// QA endpoint. + static bool get showStageStepper => useDevData || allowStageOverride; + + // ------------------------------------------------------- development access + // + // ── The dev token is not a login bypass ── + // + // The Miler app shipped one of those: `USE_NEW_API=false` handed you a fake + // "Demo Rider" on any four digits, with invented earnings. It was deleted, + // and the reason is worth restating — a build flag that swaps authentication + // for a bypass is not a development convenience, because the flag ships with + // the binary and nothing in the UI says which mode you are in. + // + // So neither switch below authenticates anybody: + // + // [devToken] carries a **real, server-issued token** you already hold. The + // server still authorises every request; this only spares you the OTP round + // trip that staging cannot complete without an SMS gateway. It is not a + // sign-in bypass — a revoked token signs the build straight back out. + // + // It is `!kReleaseMode`, so no define can put it into a release build, and it + // is named on the Account screen whenever it is on. + + /// A real access token, pasted in at build time: + /// `--dart-define=DM_DEV_TOKEN=eyJ...` + static String get devToken => + kReleaseMode ? '' : const String.fromEnvironment('DM_DEV_TOKEN'); + + /// The matching refresh token, if you have one. Without it the session + /// simply dies when the access token expires, and you paste a fresh one. + static String get devRefreshToken => + kReleaseMode ? '' : const String.fromEnvironment('DM_DEV_REFRESH_TOKEN'); + + static bool get hasDevToken => devToken.isNotEmpty; + + /// True when this build got its session from somewhere other than a real + /// sign-in — a pasted dev token, or dev data where any code is accepted. + static bool get authBypassed => hasDevToken || useDevData || hasAutoLogin; + + // -------------------------------------------------------------- client identity + + /// `X-Client: doormile-cx/1.0.0+1`. Passed in at build time because reading + /// it at runtime would mean another plugin for one string. + static const String appVersion = String.fromEnvironment( + 'DM_APP_VERSION', + defaultValue: '1.0.0+1', + ); + + static String get clientHeader => 'doormile-cx/$appVersion'; + + static String get platformHeader { + if (kIsWeb) return 'web'; + try { + if (Platform.isAndroid) return 'android'; + if (Platform.isIOS) return 'ios'; + return Platform.operatingSystem; + } catch (_) { + return 'unknown'; + } + } + + // ------------------------------------------------------------------ timeouts + + static const Duration requestTimeout = Duration(seconds: 15); + + /// Booking creation is allowed longer: the backend's own budget for it is + /// 1.2s p95, but it writes across several tables and we would rather wait + /// than orphan a booking the server did create. + static const Duration writeTimeout = Duration(seconds: 30); + + /// A one-line summary for the Account screen and for bug reports. + /// + /// Names the dev token whenever one is in use: a build whose session did not + /// come from a sign-in must say so on screen. + static String get describe => + '${environment.name} · ${useDevData ? 'DEV DATA (offline)' : baseUrl}' + '${allowStageOverride ? ' · STAGE OVERRIDE' : ''}' + '${hasDevToken ? ' · DEV TOKEN' : ''}' + '${hasAutoLogin ? ' · AUTO LOGIN ($autoLoginIdentifier)' : ''}'; +} diff --git a/lib/data/dev_doormile_api.dart b/lib/data/dev_doormile_api.dart new file mode 100644 index 0000000..4748690 --- /dev/null +++ b/lib/data/dev_doormile_api.dart @@ -0,0 +1,789 @@ +import 'dart:math'; + +import 'doormile_api.dart'; +import 'models.dart'; + +/// In-memory backend for **development and tests only**. +/// +/// It answers every call from data it holds itself — serviceability, slots, a +/// fare, and bookings it stores in memory — and it makes no network request, so +/// there is no token to obtain and nothing to 401. Any 4-digit code signs in, +/// which is the whole point of the dev bypass: `9876543210` + `1234` lands you +/// in a working app while the real backend has no SMS gateway. +/// +/// **It cannot reach a release build.** [DoormileApi.instance] returns it only +/// when [AppConfig.useDevData] is set, and that getter is `false` in a release +/// whatever the defines say — the same guard the original mock shipped behind. +/// A booking made here lives on this device and reaches no server or admin +/// console: developing the flow offline is all it is for. +class DevDoormileApi extends DoormileApi { + DevDoormileApi(); + + /// Behaviour switches for exercising the hard-to-reach states. They live on + /// this class, not on the interface, so nothing on the live path sees them. + final DevFlags flags = DevFlags(); + + final Random _rng = Random(); + + static const _fast = Duration(milliseconds: 380); + static const _slow = Duration(milliseconds: 1600); + + Duration get _latency => flags.slow ? _slow : _fast; + + /// Resolves like a network call would; throws when the error flag is on. + Future _respond( + T Function() producer, { + bool networkSensitive = true, + }) async { + await Future.delayed(_latency); + if (flags.networkError && networkSensitive) { + throw ApiException('network', 'We could not reach Doormile'); + } + return producer(); + } + + // ---------------------------------------------------------------- mock data + + /// Stands in for `GET /serviceability` — the backend owns this entirely. + static const List> _serviceAreas = [ + { + 'code': 'TN', + 'name': 'Tamil Nadu', + 'transitTag': 'Ultra-fast transit', + 'districts': [ + {'code': 'TN-CBE', 'name': 'Coimbatore', 'available': true, + 'hub': 'Coimbatore Central Hub', 'promise': 'Next-day delivery', + 'lat': 11.0168, 'lng': 76.9558}, + {'code': 'TN-MAA', 'name': 'Chennai', 'available': true, + 'hub': 'Chennai Guindy Hub', 'promise': 'Next-day delivery', + 'lat': 13.0827, 'lng': 80.2707}, + {'code': 'TN-ERD', 'name': 'Erode', 'available': true, + 'hub': 'Erode Perundurai Hub', 'promise': '2-day delivery', + 'lat': 11.341, 'lng': 77.7172}, + {'code': 'TN-SLM', 'name': 'Salem', 'available': true, + 'hub': 'Salem Fairlands Hub', 'promise': '2-day delivery', + 'lat': 11.6643, 'lng': 78.146}, + {'code': 'TN-TUP', 'name': 'Tiruppur', 'available': true, + 'hub': 'Tiruppur Avinashi Hub', 'promise': 'Next-day delivery', + 'lat': 11.1085, 'lng': 77.3411}, + {'code': 'TN-TRZ', 'name': 'Tiruchirappalli', 'available': true, + 'hub': 'Trichy Central Hub', 'promise': '2-day delivery', + 'lat': 10.7905, 'lng': 78.7047}, + {'code': 'TN-MDU', 'name': 'Madurai', 'available': false, 'note': 'Opening soon', + 'lat': 9.9252, 'lng': 78.1198}, + ], + }, + { + 'code': 'KL', + 'name': 'Kerala', + 'transitTag': 'Next-day transit', + 'districts': [ + {'code': 'KL-PKD', 'name': 'Palakkad', 'available': true, + 'hub': 'Palakkad Town Hub', 'promise': 'Next-day delivery', + 'lat': 10.7867, 'lng': 76.6548}, + {'code': 'KL-TSR', 'name': 'Thrissur', 'available': true, + 'hub': 'Thrissur Round Hub', 'promise': '2-day delivery', + 'lat': 10.5276, 'lng': 76.2144}, + {'code': 'KL-EKM', 'name': 'Ernakulam', 'available': true, + 'hub': 'Kochi Kakkanad Hub', 'promise': 'Next-day delivery', + 'lat': 9.9312, 'lng': 76.2673}, + {'code': 'KL-KKD', 'name': 'Kozhikode', 'available': false, 'note': 'Paused this week', + 'lat': 11.2588, 'lng': 75.7804}, + ], + }, + { + 'code': 'KA', + 'name': 'Karnataka', + 'transitTag': '2-day transit', + 'districts': [ + {'code': 'KA-BLR', 'name': 'Bengaluru Urban', 'available': true, + 'hub': 'Bengaluru Whitefield Hub', 'promise': 'Next-day delivery', + 'lat': 12.9716, 'lng': 77.5946}, + {'code': 'KA-MYS', 'name': 'Mysuru', 'available': true, + 'hub': 'Mysuru Hebbal Hub', 'promise': '2-day delivery', + 'lat': 12.2958, 'lng': 76.6394}, + {'code': 'KA-MNG', 'name': 'Mangaluru', 'available': false, 'note': 'Opening soon', + 'lat': 12.9141, 'lng': 74.856}, + ], + }, + { + // A serviceable state with no open district yet. + 'code': 'PY', + 'name': 'Puducherry', + 'transitTag': 'Opening soon', + 'districts': >[], + }, + ]; + + /// Stands in for `GET /pickup-slots?lat=&lng=`. + static const List _pickupSlots = [ + PickupSlot( + id: 'slot_t_1', day: 'Today', window: '2:00 – 4:00 PM', available: true, + tag: 'Fastest pickup', milersNearby: 4, caption: 'Arriving in approx. 45 mins', + ), + PickupSlot( + id: 'slot_t_2', day: 'Today', window: '4:00 – 6:00 PM', available: true, + milersNearby: 2, caption: 'Relaxed evening handover', + ), + PickupSlot( + id: 'slot_t_3', day: 'Today', window: '6:00 – 8:00 PM', available: false, + note: 'Fully booked', + ), + PickupSlot( + id: 'slot_m_1', day: 'Tomorrow', window: '9:00 – 11:00 AM', available: true, + milersNearby: 5, caption: 'Early first dispatch', + ), + PickupSlot( + id: 'slot_m_2', day: 'Tomorrow', window: '11:00 AM – 1:00 PM', available: true, + milersNearby: 3, caption: 'Mid-morning route', + ), + PickupSlot( + id: 'slot_m_3', day: 'Tomorrow', window: '2:00 – 4:00 PM', available: true, + milersNearby: 3, caption: 'Post-lunch route', + ), + ]; + + static const List _places = [ + Place( + title: '12 Nehru Street', + sub: 'Gandhipuram, Coimbatore 641012', + lat: 11.0183, + lng: 76.9725, + ), + Place( + title: 'Doormile Hub — RS Puram', + sub: 'Trichy Road, Coimbatore 641002', + lat: 11.0043, + lng: 76.9611, + ), + Place( + title: 'Brookefields Mall', + sub: 'Brookebond Road, Coimbatore 641001', + lat: 10.9987, + lng: 76.9628, + ), + Place( + title: 'Office — Tidel Park', + sub: 'Peelamedu, Coimbatore 641014', + lat: 11.0286, + lng: 77.0021, + ), + Place( + title: 'Home — 4B Lake View Apartments', + sub: 'Race Course, Coimbatore 641018', + lat: 10.9968, + lng: 76.9711, + ), + ]; + + // --------------------------------------------------------------------- auth + + @override + Future sendOtp(String identifier) => _respond( + () => const OtpChallenge(codeLength: 4, resendAfterSeconds: 30), + networkSensitive: true, + ); + + /// Registers a new customer, then sends the verification code. + @override + Future signUp({ + required String name, + required String phone, + String? email, + }) => _respond(() { + if (name.trim().length < 2) { + throw ApiException('invalid_name', 'Enter your full name'); + } + return const OtpChallenge(codeLength: 4, resendAfterSeconds: 30); + }); + + /// [name] is set when verifying a freshly created account. + @override + Future verifyOtp( + String identifier, + String code, { + String? name, + }) => _respond(() { + if (code.length < 4) { + throw ApiException('invalid_otp', 'That code did not match'); + } + final isEmail = identifier.contains('@'); + return Customer( + id: 'cust_10241', + name: name ?? 'Joe Oommen', + phone: isEmail ? '+91 98765 43210' : identifier, + email: isEmail ? identifier : 'joe@example.com', + ); + }); + + // ----------------------------------------------------------- serviceability + + @override + Future> getServiceableStates() => _respond(() { + if (flags.emptyServiceAreas) return []; + return _serviceAreas.map((s) { + final districts = (s['districts'] as List).cast>(); + return ServiceArea( + code: s['code'] as String, + name: s['name'] as String, + transitTag: s['transitTag'] as String?, + districtCount: districts.where((d) => d['available'] == true).length, + ); + }).toList(); + }); + + @override + Future> getServiceableDistricts(String stateCode) => _respond( + () { + final state = _serviceAreas + .where((s) => s['code'] == stateCode) + .firstOrNull; + if (state == null) { + throw ApiException('not_found', 'That state is no longer serviceable'); + } + return (state['districts'] as List) + .cast>() + .map( + (d) => District( + code: d['code'] as String, + name: d['name'] as String, + available: d['available'] as bool, + note: d['note'] as String?, + hub: d['hub'] as String?, + promise: d['promise'] as String?, + lat: d['lat'] as double?, + lng: d['lng'] as double?, + ), + ) + .toList(); + }, + ); + + // -------------------------------------------------------------------- slots + + @override + Future> getPickupSlots({Place? pickup}) => + _respond(() => List.from(_pickupSlots)); + + @override + PickupSlot? slotById(String? id) => + id == null ? null : _pickupSlots.where((s) => s.id == id).firstOrNull; + + // ----------------------------------------------------------------- location + + /// Turns a map pin into an address. + /// + /// The mock derives a plausible address from the coordinates so dragging the + /// map visibly changes the result — the real endpoint is + /// `GET /places/reverse-geocode?lat=&lng=` and returns the same [Place]. + @override + Future reverseGeocode({double? lat, double? lng}) => _respond(() { + if (lat == null || lng == null) return _places.first; + + // Standing on a known place? Return it by name. + for (final place in _places) { + if (place.hasLocation && + _metresBetween(lat, lng, place.lat!, place.lng!) < 160) { + return Place(title: place.title, sub: place.sub, lat: lat, lng: lng); + } + } + + final area = _nearestArea(lat, lng); + final door = 2 + ((lat * 1e4).round() + (lng * 1e4).round()).abs() % 118; + final street = _streets[((lng * 1e5).round()).abs() % _streets.length]; + return Place( + title: '$door $street', + sub: '${area.area}, ${area.city} ${area.pin}', + lat: lat, + lng: lng, + ); + }, networkSensitive: false); + + /// Neighbourhoods the mock geocoder names. Real geocoding replaces this. + static const List<({double lat, double lng, String area, String city, String pin})> + _areas = [ + (lat: 11.0183, lng: 76.9725, area: 'Gandhipuram', city: 'Coimbatore', pin: '641012'), + (lat: 11.0043, lng: 76.9611, area: 'R.S. Puram', city: 'Coimbatore', pin: '641002'), + (lat: 11.0286, lng: 77.0021, area: 'Peelamedu', city: 'Coimbatore', pin: '641014'), + (lat: 10.9968, lng: 76.9711, area: 'Race Course', city: 'Coimbatore', pin: '641018'), + (lat: 11.0281, lng: 76.9455, area: 'Saibaba Colony', city: 'Coimbatore', pin: '641011'), + (lat: 11.0069, lng: 77.0281, area: 'Singanallur', city: 'Coimbatore', pin: '641005'), + (lat: 10.9878, lng: 76.9600, area: 'Ukkadam', city: 'Coimbatore', pin: '641001'), + (lat: 11.0294, lng: 76.9048, area: 'Vadavalli', city: 'Coimbatore', pin: '641041'), + (lat: 13.0827, lng: 80.2707, area: 'Guindy', city: 'Chennai', pin: '600032'), + (lat: 12.9716, lng: 77.5946, area: 'Indiranagar', city: 'Bengaluru', pin: '560038'), + (lat: 9.9312, lng: 76.2673, area: 'Kakkanad', city: 'Kochi', pin: '682030'), + ]; + + static const List _streets = [ + 'Nehru Street', + 'Bharathi Road', + 'Kamaraj Road', + '4th Cross Street', + 'Mettupalayam Road', + 'Trichy Road', + 'Avinashi Road', + 'Lake View Road', + '2nd Main Road', + ]; + + static ({double lat, double lng, String area, String city, String pin}) + _nearestArea(double lat, double lng) { + var best = _areas.first; + var bestMetres = double.infinity; + for (final area in _areas) { + final d = _metresBetween(lat, lng, area.lat, area.lng); + if (d < bestMetres) { + bestMetres = d; + best = area; + } + } + return best; + } + + /// Great-circle distance, good enough for pin snapping and route summaries. + static double _metresBetween( + double lat1, + double lng1, + double lat2, + double lng2, + ) { + const earth = 6371000.0; + double rad(double d) => d * pi / 180; + final dLat = rad(lat2 - lat1); + final dLng = rad(lng2 - lng1); + final a = sin(dLat / 2) * sin(dLat / 2) + + cos(rad(lat1)) * cos(rad(lat2)) * sin(dLng / 2) * sin(dLng / 2); + return earth * 2 * atan2(sqrt(a), sqrt(1 - a)); + } + + /// Synchronous district lookup, so a screen can resolve the hub it already + /// has a code for without another round trip. Backed by whatever + /// [getServiceableDistricts] last returned in production. + @override + District? districtByCode(String? code) { + if (code == null) return null; + for (final state in _serviceAreas) { + for (final d in (state['districts'] as List).cast>()) { + if (d['code'] == code) { + return District( + code: d['code'] as String, + name: d['name'] as String, + available: d['available'] as bool, + note: d['note'] as String?, + hub: d['hub'] as String?, + promise: d['promise'] as String?, + lat: d['lat'] as double?, + lng: d['lng'] as double?, + ); + } + } + } + return null; + } + + /// Place search — `GET /customer/places/search?q=&lat=&lng=`. + /// + /// An empty query returns the customer's recent or saved places, which is + /// what the search sheet shows the moment it opens. + @override + Future> searchPlaces( + String query, { + double? lat, + double? lng, + }) => _respond(() { + if (query.trim().isEmpty) return _places.take(4).toList(); + final needle = query.toLowerCase(); + return _places + .where((p) => '${p.title} ${p.sub}'.toLowerCase().contains(needle)) + .toList(); + }, networkSensitive: false); + + // ------------------------------------------------------------------ limits + + /// Caps for one pickup. Backend-owned so they can change per city or tier. + @override + Future getBookingLimits({Place? pickup}) => + _respond(() => const BookingLimits(maxPackages: 20, maxDestinations: 5)); + + // --------------------------------------------------------------------- fare + + /// Indicative price for a route. Confirmed at pickup once the Miler weighs + /// and measures the parcel. + /// Combined indicative price for everything collected in one visit. The + /// final amount is settled once the Miler verifies the parcels. + @override + Future estimateFare({ + required Place pickup, + required List destinations, + }) => _respond(() { + final packages = destinations.fold(0, (n, d) => n + d.packageCount); + final stops = destinations.length; + return FareEstimate( + min: 49 * packages + 20 * (stops - 1), + max: 79 * packages + 30 * (stops - 1), + paymentMethod: 'UPI · Cash at doorstep', + parcel: packages == 1 + ? 'Standard box (up to 3 kg)' + : '$packages boxes (up to 3 kg each)', + ); + }); + + + // ------------------------------------------------------------------ booking + + /// Creates the pickup **booking**. No shipment, no tracking number yet — + /// those are generated by the backend once the Miler completes pickup. + /// Creates the pickup **booking**. No shipment and no tracking number yet — + /// those are generated per destination once the Miler completes pickup. + @override + Future createBooking({ + required Place pickup, + required List destinations, + required String? slotId, + FareEstimate? fare, + String? idempotencyKey, + }) => _respond(() { + if (destinations.isEmpty || + destinations.any((d) => !d.destination.isComplete)) { + throw ApiException( + 'invalid', + 'Every destination needs a serviceable state and district', + ); + } + if (slotId == null) throw ApiException('invalid', 'Pick a pickup slot'); + + final booking = Booking( + reference: newReference(), + pickup: pickup, + destinations: destinations.map((d) => d.copy()).toList(), + slotId: slotId, + createdAt: DateTime.now(), + fare: fare, + history: [StageEvent(JourneyStage.booked, DateTime.now())], + ); + _bookings.insert(0, booking); + return booking; + }); + + @override + Future cancelBooking(String reference, String? reason) => + _respond(() { + final booking = _find(reference); + if (booking == null) return; + booking + ..status = BookingStatus.cancelled + ..cancelReason = reason + ..cancellable = false; + }); + + /// The customer's bookings — `GET /customer/bookings`. + /// + /// This fake owns the list, exactly as the backend does. [AppState] adopts + /// whatever comes back, so a test that wants a booking on screen creates one + /// here rather than reaching into app state. + @override + Future getBookingPage({ + BookingStatus? status, + String? cursor, + int limit = 20, + }) => _respond(() { + // One page: the fake never has enough bookings to need a second, and a + // cursor that led nowhere would only make the paging tests lie. + final rows = status == null + ? _bookings + : _bookings.where((b) => b.status == status).toList(); + return BookingPage(bookings: List.of(rows)); + }); + + /// One booking, fresh — `GET /customer/bookings/{reference}`. + @override + Future getBooking(String reference) => + _respond(() => _find(reference)); + + /// Backend policy decides this; the UI only asks. + @override + bool isCancellable(JourneyStage stage) => + stage.index < JourneyStage.arrived.index; + + /// The QA stage helper — `POST /customer/ops/bookings/{ref}/stage`. + @override + Future setStage(String reference, JourneyStage stage) => + _respond(() { + final booking = _find(reference); + if (booking != null) { + applyStage(booking, stage, DateTime.now(), + backfillGap: const Duration(minutes: 9)); + } + }); + + Booking? _find(String reference) { + for (final b in _bookings) { + if (b.reference == reference) return b; + } + return null; + } + + // ------------------------------------------------------------------- ids + + String newReference() => 'DM-${100000 + _rng.nextInt(899999)}'; + + String newTrackingId() => 'DMX${10000000 + _rng.nextInt(89999999)}'; + + // ------------------------------------------------------------- stage machine + + /// Everything the **backend** does as a booking moves — assigning a Miler, + /// minting a tracking number per destination, weighing the parcels, settling + /// the amount paid. + /// + /// It lives here because it is the server's work. It used to live in + /// `AppState`, where a stepper in the UI drove it, which meant the app itself + /// could mint a tracking number the backend had never issued. + void applyStage( + Booking booking, + JourneyStage target, + DateTime now, { + required Duration backfillGap, + }) { + booking.stage = target; + + // Rebuild history so stepping backwards drops the later entries. + booking.history = [ + for (var i = 0; i <= target.index; i++) + StageEvent( + JourneyStage.at(i), + now.subtract(backfillGap * (target.index - i)), + ), + ]; + + if (target.index >= JourneyStage.assigned.index) { + booking.miler ??= const Person( + name: 'Arun Kumar', + vehicle: 'TN 37 BX 4412', + phone: '+91 90000 11223', + ); + } + + switch (target) { + case JourneyStage.onTheWay: + booking.milerDistanceKm = 1.4; + booking.milerEtaMinutes = 12; + case JourneyStage.arrived: + booking.milerDistanceKm = 0; + booking.milerEtaMinutes = 0; + case _: + booking.milerDistanceKm = null; + booking.milerEtaMinutes = null; + } + + // The parcels are weighed and photographed at pickup, which is also when + // the price settles. + if (target.index >= JourneyStage.pickedUp.index) { + booking.amountPaid ??= booking.fare?.max ?? 64; + for (final group in booking.destinations) { + group.verification ??= ParcelVerification( + weightKg: 1.2 * group.packageCount + 0.4, + photos: [ + for (var i = 1; i <= group.packageCount; i++) + '${group.destination.districtCode}-$i', + ], + capturedAt: now, + capturedBy: booking.miler?.name, + ); + } + } else { + booking.amountPaid = null; + for (final group in booking.destinations) { + group.verification = null; + } + } + + booking.deliveredAt = + target == JourneyStage.delivered ? (booking.deliveredAt ?? now) : null; + + // The backend turns the pickup into one order per destination here. Only + // at this point do the destinations become separately trackable. + if (target.index >= JourneyStage.orderCreated.index) { + booking.expectedDelivery ??= 'Thu, 12 Sep'; + for (var i = 0; i < booking.destinations.length; i++) { + final group = booking.destinations[i]; + group.trackingId ??= newTrackingId(); + group.details.recipientName ??= 'Collected at pickup'; + group.details.street ??= 'Confirmed by Miler at pickup'; + // Later destinations trail slightly, so independent journeys are + // visible. Real stages come from the backend per order. + group.stage = JourneyStage.at( + (target.index - i).clamp( + JourneyStage.orderCreated.index, + JourneyStage.delivered.index, + ), + ); + } + } else { + for (final group in booking.destinations) { + group.trackingId = null; + group.stage = null; + } + } + + if (target.index >= JourneyStage.outForDelivery.index) { + booking.deliveryAgent ??= const Person( + name: 'Vishal R', + vehicle: 'KL 07 CD 8890', + phone: '+91 90000 44556', + ); + } + + booking.status = target == JourneyStage.delivered + ? BookingStatus.completed + : BookingStatus.active; + booking.cancellable = isCancellable(target); + } + + // ------------------------------------------------------------- seeded rows + + late final List _bookings = _seed(); + + /// Clears the account back to its seeded state. + void reset() { + _bookings + ..clear() + ..addAll(_seed()); + } + + /// One booking per interesting stage, so every tracking state is reachable + /// without stepping a booking through the whole journey. + List _seed() { + final now = DateTime.now(); + + DestinationGroup group( + String stateCode, + String stateName, + String districtCode, + String districtName, { + int packages = 1, + DeliveryDetails? details, + }) => DestinationGroup( + destination: Destination( + stateCode: stateCode, + stateName: stateName, + districtCode: districtCode, + districtName: districtName, + ), + packageCount: packages, + details: details, + )..district = districtByCode(districtCode); + + Booking make({ + required String reference, + required JourneyStage stage, + required DateTime createdAt, + required String slotId, + required List destinations, + BookingStatus? status, + }) { + final b = Booking( + reference: reference, + pickup: const Place( + title: '12 Nehru Street', + sub: 'Gandhipuram, Coimbatore 641012', + ), + destinations: destinations, + slotId: slotId, + createdAt: createdAt, + fare: const FareEstimate( + min: 49, + max: 64, + paymentMethod: 'UPI · Cash at doorstep', + ), + ); + applyStage(b, stage, createdAt, backfillGap: const Duration(minutes: 22)); + if (status != null) { + b.status = status; + b.cancellable = false; + } + return b; + } + + return [ + make( + reference: 'DM-482913', + stage: JourneyStage.assigned, + createdAt: now.subtract(const Duration(minutes: 40)), + slotId: 'slot_t_1', + destinations: [ + group( + 'TN', 'Tamil Nadu', 'TN-MAA', 'Chennai', + details: DeliveryDetails( + recipientName: 'Meera S', + recipientPhone: '9884412210', + ), + ), + ], + ), + // A multi-destination pickup: one visit, three places. + make( + reference: 'DM-471200', + stage: JourneyStage.inTransit, + createdAt: now.subtract(const Duration(hours: 26)), + slotId: 'slot_m_1', + destinations: [ + group('KL', 'Kerala', 'KL-EKM', 'Ernakulam', packages: 2), + group('TN', 'Tamil Nadu', 'TN-CBE', 'Coimbatore'), + ], + ), + make( + reference: 'DM-466118', + stage: JourneyStage.outForDelivery, + createdAt: now.subtract(const Duration(hours: 50)), + slotId: 'slot_t_2', + destinations: [ + group( + 'KA', 'Karnataka', 'KA-BLR', 'Bengaluru Urban', + details: DeliveryDetails( + street: 'Indiranagar 12th Main', + recipientName: 'Divya P', + recipientPhone: '9845567712', + ), + ), + ], + ), + make( + reference: 'DM-451007', + stage: JourneyStage.delivered, + createdAt: now.subtract(const Duration(days: 5)), + slotId: 'slot_m_2', + destinations: [ + group( + 'TN', 'Tamil Nadu', 'TN-SLM', 'Salem', + details: DeliveryDetails( + street: 'Fairlands', + recipientName: 'Karthik M', + ), + ), + ], + ), + make( + reference: 'DM-449220', + stage: JourneyStage.booked, + createdAt: now.subtract(const Duration(days: 8)), + slotId: 'slot_t_1', + destinations: [group('TN', 'Tamil Nadu', 'TN-ERD', 'Erode')], + status: BookingStatus.cancelled, + ), + ]; + } +} + +/// Behaviour switches for [DevDoormileApi]. These used to sit on the +/// `DoormileApi` interface, where shipping code could read them; they are +/// dev/test-only now. +class DevFlags { + bool emptyServiceAreas = false; + bool networkError = false; + bool slow = false; +} + +extension _FirstOrNull on Iterable { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/data/doormile_api.dart b/lib/data/doormile_api.dart new file mode 100644 index 0000000..6ed7d96 --- /dev/null +++ b/lib/data/doormile_api.dart @@ -0,0 +1,177 @@ +import 'app_config.dart'; +import 'dev_doormile_api.dart'; +import 'live_doormile_api.dart'; +import 'models.dart'; + +export 'api_exception.dart'; + +/// The service surface the app is written against. +/// +/// **A shipping build has one implementation: [LiveDoormileApi],** against +/// `/customer/*` on `api.doormile.com`. When the server cannot be reached the +/// screens show their designed error state, which is the only honest answer. +/// +/// The single exception is [DevDoormileApi], reached only when +/// [AppConfig.useDevData] is set — a `!kReleaseMode` guard, so no define swaps +/// invented data into a release. It exists because the real backend has no SMS +/// gateway, so there is otherwise no way to sign in and exercise the flow. +/// +/// Screens never see this type at all — they call [AppState], which holds it. +/// +/// A method the backend owns but no screen drives yet has a default here that +/// does nothing rather than throwing. +abstract class DoormileApi { + DoormileApi(); + + static DoormileApi? _instance; + + /// The API this build talks to: the live backend, or [DevDoormileApi] when + /// [AppConfig.useDevData] allows it — which is never in a release build. + static DoormileApi get instance => + _instance ??= AppConfig.useDevData ? DevDoormileApi() : LiveDoormileApi(); + + /// Replaces the singleton. Tests inject their double through here. + static void overrideInstance(DoormileApi? api) => _instance = api; + + /// Called when the refresh chain dies and the customer must sign in again. + /// Set by [AppState]. + void Function()? onSessionLost; + + // --------------------------------------------------------------------- auth + + /// Asks for a code. The answer describes the challenge — how many digits it + /// has and how long before it can be resent — so the screen is built from + /// what the server said rather than from constants. + Future sendOtp(String identifier); + + /// Creates the account **and** sends the first code, so it answers with the + /// same challenge [sendOtp] does. + Future signUp({ + required String name, + required String phone, + String? email, + }); + + /// [name] is set when verifying a freshly created account. + Future verifyOtp(String identifier, String code, {String? name}); + + /// Restores a persisted session at launch, or null when there is none. + Future restoreSession() async => null; + + /// Revokes the refresh token and unregisters the push token. + Future signOut() async {} + + /// Re-reads the signed-in customer — `GET /customer/auth/me`. + Future me() async => null; + + // ----------------------------------------------------------- serviceability + + Future> getServiceableStates(); + + Future> getServiceableDistricts(String stateCode); + + /// Synchronous district lookup for a code we already hold, backed by + /// whatever [getServiceableDistricts] last returned. + District? districtByCode(String? code); + + // -------------------------------------------------------------------- slots + + Future> getPickupSlots({Place? pickup}); + + PickupSlot? slotById(String? id); + + // ----------------------------------------------------------------- location + + Future reverseGeocode({double? lat, double? lng}); + + /// An empty query returns the customer's saved and recent places, which is + /// what the search sheet shows the moment it opens. + /// + /// [lat]/[lng] bias the results towards where the customer is looking — + /// "MG Road" is in most Indian cities, and the one they mean is the near one. + Future> searchPlaces(String query, {double? lat, double? lng}); + + // ------------------------------------------------------------------- limits + + Future getBookingLimits({Place? pickup}); + + // --------------------------------------------------------------------- fare + + Future estimateFare({ + required Place pickup, + required List destinations, + }); + + // ------------------------------------------------------------------ booking + + /// Creates the pickup booking. No tracking number yet — those are minted per + /// destination when the Miler completes the pickup. + /// + /// [idempotencyKey] must be **held across retries of the same intent**. A new + /// key is a new booking, which is exactly what a double tap must not create. + Future createBooking({ + required Place pickup, + required List destinations, + required String? slotId, + FareEstimate? fare, + String? idempotencyKey, + }); + + Future cancelBooking(String reference, String? reason); + + /// One page of the customer's bookings, newest first. + Future getBookingPage({ + BookingStatus? status, + String? cursor, + int limit = 20, + }); + + /// One booking, fresh. Null means the server answered `304` — nothing has + /// changed since the last read, so the caller keeps what it has. + Future getBooking(String reference); + + /// One order by its tracking number — for a push deep link. + Future getOrder(String trackingId) async => null; + + /// Adds the optional address and recipient after booking, up to collection. + Future updateDestinationDetails( + String reference, + int index, + DeliveryDetails details, + ) async {} + + /// Backend policy decides this; the UI only asks. + bool isCancellable(JourneyStage stage); + + // ------------------------------------------------------------------ ops QA + + /// Moves a booking to [stage] through the backend's own non-production QA + /// helper — `POST /customer/ops/bookings/{reference}/stage`. + /// + /// It exists so the tracking screen's stepper can walk a **real** staging + /// booking. The stage that then appears on screen is the one the server + /// recorded, not one the client wished for. Refused on production. + Future setStage(String reference, JourneyStage stage) async {} + + // ------------------------------------------------------------------ devices + + Future registerDevice(String token) async {} + + Future unregisterDevice(String token) async {} + + // ------------------------------------------------------- profile & addresses + + Future getProfile() async => null; + + Future updateProfile({String? name, String? email}) async => null; + + Future> getSavedLocations() async => const []; + + Future addSavedLocation(Place place, {String? label}) async => + null; + + Future updateSavedLocation(SavedPlace place) async => null; + + Future deleteSavedLocation(String id) async {} + +} diff --git a/lib/data/live_doormile_api.dart b/lib/data/live_doormile_api.dart new file mode 100644 index 0000000..38699ab --- /dev/null +++ b/lib/data/live_doormile_api.dart @@ -0,0 +1,603 @@ +import 'package:flutter/foundation.dart'; + +import 'api_client.dart'; +import 'app_config.dart'; +import 'doormile_api.dart'; +import 'models.dart'; +import 'session_store.dart'; + +/// The real customer API — `/customer/*` on `api.doormile.com/api/v1`. +/// +/// Every method is a thin translation between the app's models and the wire +/// contract. The transport concerns — envelope, error codes, bearer auth and +/// its refresh, idempotency, `ETag` revalidation, retries — all live in +/// [ApiClient], so what is left here is the shape of each endpoint and nothing +/// else. +class LiveDoormileApi extends DoormileApi { + LiveDoormileApi({ApiClient? client}) : client = client ?? ApiClient() { + // Forwarded rather than passed in: `onSessionLost` is set on the interface + // by AppState, which does not exist yet at this point. + this.client.onSessionLost = () => onSessionLost?.call(); + } + + final ApiClient client; + + // ── Caches that exist to answer synchronous questions ──────────────────── + // + // Two screens ask for a district or a slot by the code they already hold, + // without a round trip. Neither is a substitute for the fetch: each is + // populated only by a real response, so an entry can never be invented. + + final Map _districtsByCode = {}; + final Map _slotsById = {}; + + /// `ETag`s keyed by path, so a revalidated read costs a 304 and no body. + final Map _etags = {}; + final Map> _statesCache = {}; + final Map> _districtCache = {}; + final Map> _slotCache = {}; + + // --------------------------------------------------------------------- auth + + @override + Future sendOtp(String identifier) async { + final response = await client.post( + '/auth/otp/request', + body: {'identifier': _normalisePhone(identifier)}, + authenticated: false, + ); + return OtpChallenge.fromJson(response.map); + } + + @override + Future signUp({ + required String name, + required String phone, + String? email, + }) async { + final response = await client.post( + '/auth/signup', + body: { + 'name': name.trim(), + 'phone': _normalisePhone(phone), + if (email != null && email.trim().isNotEmpty) 'email': email.trim(), + }, + authenticated: false, + ); + return OtpChallenge.fromJson(response.map); + } + + @override + Future verifyOtp( + String identifier, + String code, { + String? name, + }) async { + final response = await client.post( + '/auth/otp/verify', + // The field is `code`, NOT `otp`. The written contract says `otp`, but + // the server reads `json:"code"` (CxVerifyOtp) and rejects a body without + // it as an empty code — "Enter the code we sent you", a 400, on every + // single sign-in. Verified against production: `otp` returns that 400, + // `code` returns 401 "That code did not match" for a wrong code and a + // session for a right one. Follow the server, not the document. + // + // The name is deliberately not sent: `POST /auth/signup` has already + // recorded it, and it is not part of this request. + body: {'identifier': _normalisePhone(identifier), 'code': code}, + authenticated: false, + // Verification mints a session. A retry over a flaky network must return + // the same one rather than a second, or the first is orphaned. + idempotencyKey: client.newIdempotencyKey(), + ); + + final data = response.map; + final access = data['accessToken'] as String?; + final refresh = data['refreshToken'] as String?; + if (access == null || refresh == null) { + throw ApiException( + ApiException.serverError, + 'Something went wrong', + response.data == null ? null : 200, + response.requestId, + ); + } + + final customer = Customer.fromJson( + (data['customer'] as Map?) ?? const {}, + ); + + await client.adoptSession( + Session( + accessToken: access, + refreshToken: refresh, + expiresAt: DateTime.now().add( + Duration(seconds: (data['expiresIn'] as num?)?.toInt() ?? 3600), + ), + customer: customer, + ), + ); + return customer; + } + + @override + Future restoreSession() async { + await _adoptDevTokenIfGiven(); + final session = await client.currentSession(); + if (session == null) return null; + // A stored session is a claim, not a fact — the refresh token may have been + // revoked from another device. Ask the server who this is; a 401 here means + // the chain is dead and ApiClient has already cleared it. + try { + final me = await this.me(); + return me ?? session.customer; + } on ApiException catch (e) { + if (e.isAuthFailure) return null; + // Offline at launch is not a signed-out customer. Trust the stored copy + // and let the first real call settle it. + return session.customer; + } + } + + @override + Future me() async { + final response = await client.get('/auth/me'); + if (response.data is! Map) return null; + final data = response.map; + final customer = Customer.fromJson( + (data['customer'] as Map?) ?? data, + ); + final session = await client.currentSession(); + if (session != null) { + await client.adoptSession(session.copyWith(customer: customer)); + } + return customer; + } + + @override + Future signOut() async { + final session = await client.currentSession(); + try { + await client.post( + '/auth/logout', + body: {'refreshToken': ?session?.refreshToken}, + ); + } on ApiException catch (e) { + // Signing out locally must always succeed. A server that refuses only + // means the refresh token outlives the device, which its 60-day TTL + // settles anyway. + debugPrint('[AUTH] logout call failed, clearing locally: $e'); + } finally { + await client.dropSession(); + _districtsByCode.clear(); + _slotsById.clear(); + _etags.clear(); + _statesCache.clear(); + _districtCache.clear(); + _slotCache.clear(); + } + } + + /// Adopts a token handed in at build time, when there is no stored session. + /// + /// The token is real and server-issued — this skips the OTP round trip that + /// staging cannot complete without an SMS gateway, not the authorisation. + /// Every request it signs is still checked server-side, and a revoked token + /// signs the build straight back out. + /// + /// A stored session always wins: a real sign-in on the device must not be + /// silently replaced by whatever was compiled in weeks ago. + Future _adoptDevTokenIfGiven() async { + if (!AppConfig.hasDevToken) return; + if (await client.currentSession() != null) return; + debugPrint('[AUTH] using DM_DEV_TOKEN — no sign-in was performed'); + await client.adoptSession( + Session( + accessToken: AppConfig.devToken, + refreshToken: AppConfig.devRefreshToken, + // Without a refresh token there is nothing to rotate, so do not spend + // a call discovering that. The token dies when the server says so. + expiresAt: DateTime.now().add( + AppConfig.devRefreshToken.isEmpty + ? const Duration(days: 365) + : const Duration(hours: 1), + ), + ), + ); + } + + /// The client sends `+91 98765 43210` today; the server normalises, but + /// sending E.164 costs nothing and removes the ambiguity. + static String _normalisePhone(String identifier) { + final trimmed = identifier.trim(); + if (trimmed.contains('@')) return trimmed; + final digits = trimmed.replaceAll(RegExp(r'[^0-9+]'), ''); + if (digits.startsWith('+')) return digits; + if (digits.length == 10) return '+91$digits'; + if (digits.length == 12 && digits.startsWith('91')) return '+$digits'; + return digits.isEmpty ? trimmed : digits; + } + + // ----------------------------------------------------------- serviceability + + @override + Future> getServiceableStates() async { + const path = '/serviceability/states'; + final response = await client.get(path, ifNoneMatch: _etags[path]); + if (response.notModified) return _statesCache[path] ?? const []; + + final states = response.rows.map(ServiceArea.fromJson).toList(); + if (response.etag != null) _etags[path] = response.etag!; + _statesCache[path] = states; + return states; + } + + @override + Future> getServiceableDistricts(String stateCode) async { + final path = '/serviceability/states/$stateCode/districts'; + final response = await client.get(path, ifNoneMatch: _etags[path]); + if (response.notModified) return _districtCache[path] ?? const []; + + // The full list arrives, unavailable districts included: the picker filters + // them out but their names feed the "Coming soon" line. + final districts = response.rows.map(District.fromJson).toList(); + for (final district in districts) { + _districtsByCode[district.code] = district; + } + if (response.etag != null) _etags[path] = response.etag!; + _districtCache[path] = districts; + return districts; + } + + @override + District? districtByCode(String? code) => + code == null ? null : _districtsByCode[code]; + + // -------------------------------------------------------------------- slots + + @override + Future> getPickupSlots({Place? pickup}) async { + final query = { + if (pickup?.lat != null) 'lat': '${pickup!.lat}', + if (pickup?.lng != null) 'lng': '${pickup!.lng}', + }; + // Tagged per zone: the capacity in one zone's windows says nothing about + // another's, so the coordinates are part of the cache key. + final tag = '/pickup-slots?${query.entries.map((e) => '${e.key}=${e.value}').join('&')}'; + final response = await client.get( + '/pickup-slots', + query: query, + ifNoneMatch: _etags[tag], + ); + if (response.notModified) return _slotCache[tag] ?? const []; + + final slots = response.rows.map(PickupSlot.fromJson).toList(); + for (final slot in slots) { + _slotsById[slot.id] = slot; + } + if (response.etag != null) _etags[tag] = response.etag!; + _slotCache[tag] = slots; + return slots; + } + + @override + PickupSlot? slotById(String? id) => id == null ? null : _slotsById[id]; + + // ----------------------------------------------------------------- location + + @override + Future reverseGeocode({double? lat, double? lng}) async { + if (lat == null || lng == null) { + throw ApiException(ApiException.invalid, 'No location to look up'); + } + final response = await client.get( + '/places/reverse-geocode', + query: {'lat': '$lat', 'lng': '$lng'}, + authenticated: true, + ); + final place = Place.fromJson(response.map); + // The endpoint echoes the coordinates, but the pin is the authority on + // where the Miler is sent — never let a geocoder move it. + return Place(title: place.title, sub: place.sub, lat: lat, lng: lng); + } + + @override + Future> searchPlaces(String query, {double? lat, double? lng}) async { + final response = await client.get( + '/places/search', + query: { + 'q': query.trim(), + // Biases the results towards where the customer is looking. The + // contract takes both and an empty query then answers with their + // recent and saved places instead. + if (lat != null) 'lat': '$lat', + if (lng != null) 'lng': '$lng', + }, + ); + return response.rows.map(Place.fromJson).toList(); + } + + // ------------------------------------------------------------------- limits + + @override + Future getBookingLimits({Place? pickup}) async { + final response = await client.get( + '/config/booking-limits', + query: { + if (pickup?.lat != null) 'lat': '${pickup!.lat}', + if (pickup?.lng != null) 'lng': '${pickup!.lng}', + }, + ); + return BookingLimits.fromJson(response.map); + } + + // --------------------------------------------------------------------- fare + + @override + Future estimateFare({ + required Place pickup, + required List destinations, + }) async { + final response = await client.post( + '/fare/estimate', + body: { + // The contract also accepts a `stateCode`/`districtCode` on the pickup. + // This app does not have them: the pickup is a map pin, and the + // reverse geocoder returns an address, not a serviceable-area code. The + // coordinates say the same thing and the server already resolves them. + 'pickup': {'latitude': ?pickup.lat, 'longitude': ?pickup.lng}, + 'destinations': [ + for (final d in destinations) + { + 'stateCode': ?d.destination.stateCode, + 'districtCode': ?d.destination.districtCode, + // One entry per package, and no `weightKg` on any of them: this + // app never asks the customer what a parcel weighs — the Miler + // weighs it at the door, which is when the price settles — so the + // count is all we honestly know, and the server prices its own + // default band from it. + 'packages': [ + for (var i = 0; i < d.packageCount; i++) const {}, + ], + }, + ], + }, + timeout: null, + ); + final fare = FareEstimate.fromJson(response.map); + if (fare == null) { + throw ApiException( + ApiException.serverError, + 'We could not price that just now', + ); + } + return fare; + } + + // ------------------------------------------------------------------ booking + + @override + Future createBooking({ + required Place pickup, + required List destinations, + required String? slotId, + FareEstimate? fare, + String? idempotencyKey, + }) async { + if (slotId == null || slotId.isEmpty) { + throw ApiException(ApiException.invalid, 'Pick a pickup slot'); + } + // [fare] is deliberately not sent. The contract's create request has no + // field for what the customer was quoted, and an undocumented extra on the + // one call that must not be rejected is not worth the audit trail. The + // server prices the booking itself; the shown band lives in the estimate + // call's own log. + // + // Who the Miler asks for at the door. The contract asks for it on the + // pickup, and the signed-in customer is the only answer this app has. + final customer = (await client.currentSession())?.customer; + + final response = await client.post( + '/bookings', + idempotencyKey: idempotencyKey ?? client.newIdempotencyKey(), + body: { + 'slotId': slotId, + 'pickup': pickup.toBookingJson( + contactName: customer?.name, + contactPhone: customer?.phone, + ), + 'destinations': [for (final d in destinations) d.toBookingJson()], + // The delivery instructions the customer typed per destination. The + // contract carries one `remarks` line for the whole visit, which is + // what the Miler reads, so several are joined rather than dropped. + 'remarks': ?_remarksFrom(destinations), + }, + ); + return _bookingFrom(response.map); + } + + static String? _remarksFrom(List destinations) { + final lines = [ + for (final d in destinations) + if (d.details.instructions?.trim().isNotEmpty ?? false) + d.details.instructions!.trim(), + ]; + return lines.isEmpty ? null : lines.join(' · '); + } + + @override + Future cancelBooking(String reference, String? reason) async { + await client.post( + '/bookings/$reference/cancel', + body: {'reason': ?reason}, + ); + } + + @override + Future getBookingPage({ + BookingStatus? status, + String? cursor, + int limit = 20, + }) async { + final response = await client.get( + '/bookings', + query: { + if (status != null) 'status': status.name, + 'limit': '$limit', + 'cursor': ?cursor, + }, + ); + return BookingPage( + bookings: response.rows.map(_bookingFrom).toList(), + nextCursor: response.nextCursor, + total: response.total, + ); + } + + @override + Future getBooking(String reference) async { + final path = '/bookings/$reference'; + final response = await client.get(path, ifNoneMatch: _etags[path]); + // 304 is the common answer while the tracking screen polls: nothing moved, + // so the caller keeps what it already has. + if (response.notModified) return null; + if (response.etag != null) _etags[path] = response.etag!; + return _bookingFrom(response.map); + } + + @override + Future getOrder(String trackingId) async { + final response = await client.get('/orders/$trackingId'); + return _bookingFrom(response.map); + } + + @override + Future updateDestinationDetails( + String reference, + int index, + DeliveryDetails details, + ) async { + await client.patch( + '/bookings/$reference/destinations/$index', + body: details.toPatchJson(), + ); + } + + /// Mirrors the server's policy so the button can be hidden, but the server + /// re-checks and is the authority — this only decides what is offered. + /// + /// The window closes **when the Miler arrives**, so `arrived` is the cutoff + /// and is itself no longer cancellable. Offering it one stage too long is not + /// a cosmetic slip: the customer taps Cancel with the Miler at the door, the + /// server answers `BOOKING_NOT_CANCELLABLE`, and the app has to explain a + /// refusal it invited. + @override + bool isCancellable(JourneyStage stage) => + stage.index < JourneyStage.arrived.index; + + Booking _bookingFrom(Map json) { + final booking = Booking.fromJson(json); + // Attach the hub we already know for each district, so the route map can + // draw its line without another round trip. + for (final group in booking.destinations) { + group.district ??= districtByCode(group.destination.districtCode); + } + return booking; + } + + // ------------------------------------------------------------------ ops QA + + /// Walks a booking through the stages on a non-production backend. + /// + /// This is the server's own QA helper — double-gated behind its + /// `CX_ALLOW_STAGE_OVERRIDE` — and it is what the tracking screen's stepper + /// drives when the build asks for it against staging. It + /// refuses to leave the app on production before the request is made, + /// because a stage the client asked for is not a stage that happened. + @override + Future setStage(String reference, JourneyStage stage) async { + if (AppConfig.isProd) { + throw ApiException( + ApiException.forbidden, + 'Stage override is not available on production', + ); + } + await client.post( + '/ops/bookings/$reference/stage', + body: {'stage': stage.key}, + ); + } + + // ------------------------------------------------------------------ devices + + @override + Future registerDevice(String token) async { + await client.post( + '/devices', + body: { + 'token': token, + 'platform': _platformName, + 'appVersion': _appVersion, + }, + ); + } + + @override + Future unregisterDevice(String token) async { + await client.delete('/devices/$token'); + } + + static String get _platformName => AppConfig.platformHeader; + static String get _appVersion => AppConfig.appVersion; + + // ------------------------------------------------------- profile & addresses + + @override + Future getProfile() async { + final response = await client.get('/profile'); + if (response.data is! Map) return null; + return Customer.fromJson(response.map); + } + + @override + Future updateProfile({String? name, String? email}) async { + final response = await client.put( + '/profile', + body: {'name': ?name, 'email': ?email}, + ); + if (response.data is! Map) return null; + return Customer.fromJson(response.map); + } + + @override + Future> getSavedLocations() async { + final response = await client.get('/locations'); + return response.rows.map(SavedPlace.fromJson).toList(); + } + + @override + Future addSavedLocation(Place place, {String? label}) async { + final response = await client.post( + '/locations', + body: {...place.toJson(), 'label': ?label}, + ); + if (response.data is! Map) return null; + return SavedPlace.fromJson(response.map); + } + + @override + Future updateSavedLocation(SavedPlace place) async { + final response = await client.put( + '/locations/${place.id}', + body: place.toJson(), + ); + if (response.data is! Map) return null; + return SavedPlace.fromJson(response.map); + } + + @override + Future deleteSavedLocation(String id) async { + await client.delete('/locations/$id'); + } + +} diff --git a/lib/data/location_service.dart b/lib/data/location_service.dart new file mode 100644 index 0000000..d5434f2 --- /dev/null +++ b/lib/data/location_service.dart @@ -0,0 +1,173 @@ +import 'package:geolocator/geolocator.dart'; + +/// Why a location fix is not available. Each value maps to a distinct thing we +/// tell the customer, so none of them is a generic failure. +enum LocationDenial { + /// Device location is switched off entirely. + serviceOff, + + /// Permission refused this time; asking again is allowed. + denied, + + /// Refused permanently — only Settings can undo it. + deniedForever, + + /// Permission granted, but the fix timed out or the platform failed. + unavailable, +} + +/// A resolved device position, or the reason there isn't one. +class LocationFix { + const LocationFix.ok(this.lat, this.lng, {this.accuracy}) : denial = null; + const LocationFix.denied(this.denial) + : lat = null, + lng = null, + accuracy = null; + + final double? lat; + final double? lng; + + /// Horizontal accuracy in metres, when the platform reports it. Drawn as the + /// halo around the blue dot, so the customer can see how sure we are. + final double? accuracy; + + final LocationDenial? denial; + + bool get hasFix => lat != null && lng != null; + + /// What to show the customer when there is no fix. Never a raw error. + String get message => switch (denial) { + LocationDenial.serviceOff => + 'Turn on location to drop the pin for you. You can still search for the address.', + LocationDenial.denied => + 'Allow location access and we will drop the pin for you.', + LocationDenial.deniedForever => + 'Location is blocked for Doormile. Enable it in Settings, or search for the address.', + _ => 'We could not get your location. Search for the address instead.', + }; +} + +/// The only place the app touches platform GPS. +/// +/// Every failure — permission refused, location off, no plugin (widget tests) — +/// comes back as a [LocationFix] with a [LocationDenial]. Nothing throws, so a +/// caller never has to guard the booking flow behind a try/catch. +class LocationService { + LocationService(); + + /// Swappable so tests can answer without a platform. + static LocationService instance = LocationService(); + + /// Coimbatore city centre. Where the map opens before we know better, so the + /// customer never stares at a grey ocean tile. + static const fallbackLat = 11.0168; + static const fallbackLng = 76.9558; + + /// How long to wait on a permission or service answer before giving up. + static const _probe = Duration(seconds: 4); + + /// Asks for permission if we do not have it yet, then returns a fix. + /// + /// Every platform call is bounded: a wedged location plugin must never leave + /// the customer watching "Getting address…" with no way forward. + Future current({ + Duration timeout = const Duration(seconds: 12), + }) async { + final enabled = await _guard(Geolocator.isLocationServiceEnabled, _probe); + if (enabled == null) { + return const LocationFix.denied(LocationDenial.unavailable); + } + if (!enabled) return const LocationFix.denied(LocationDenial.serviceOff); + + var permission = await _guard(Geolocator.checkPermission, _probe); + if (permission == LocationPermission.denied) { + permission = await _guard(Geolocator.requestPermission, timeout); + } + if (permission == null) { + return const LocationFix.denied(LocationDenial.unavailable); + } + if (permission == LocationPermission.deniedForever) { + return const LocationFix.denied(LocationDenial.deniedForever); + } + if (permission == LocationPermission.denied) { + return const LocationFix.denied(LocationDenial.denied); + } + + final fix = await _guard( + () => Geolocator.getCurrentPosition( + locationSettings: LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: timeout, + ), + ), + timeout, + // A cached fix beats no fix at all when the precise one times out. + ) ?? await _guard(() => Geolocator.getLastKnownPosition(), _probe); + + return fix == null + ? const LocationFix.denied(LocationDenial.unavailable) + : LocationFix.ok( + fix.latitude, + fix.longitude, + accuracy: fix.accuracy > 0 ? fix.accuracy : null, + ); + } + + /// Runs a platform call, returning null instead of throwing or hanging. + /// Covers MissingPluginException, a refused channel, and a plugin that + /// simply never answers. + static Future _guard(Future Function() call, Duration limit) async { + try { + return await call().timeout(limit); + } on Object { + return null; + } + } + + /// Opens the OS settings page so a permanently denied customer can recover. + Future openSettings() async { + try { + await Geolocator.openAppSettings(); + } on Object { + // Nothing useful to do if the platform refuses. + } + } +} + +/// Answers instantly with a known position, without touching the platform. +/// +/// **A test seam, and only that.** Widget tests run on a fake clock that a real +/// platform-channel reply never reaches, so every test that renders the pickup +/// screen installs one of these as [LocationService.instance]. Nothing in the +/// app does, and nothing should: a build that invents where the customer is +/// sends a Miler to the wrong door. +class FixedLocationService extends LocationService { + FixedLocationService({ + this.lat = LocationService.fallbackLat, + this.lng = LocationService.fallbackLng, + this.accuracy = 18, + this.denial, + }); + + /// A service that always refuses, for exercising the denied states. + FixedLocationService.denied(LocationDenial reason) + : lat = LocationService.fallbackLat, + lng = LocationService.fallbackLng, + accuracy = null, + denial = reason; + + final double lat; + final double lng; + final double? accuracy; + final LocationDenial? denial; + + @override + Future current({ + Duration timeout = const Duration(seconds: 12), + }) async => denial != null + ? LocationFix.denied(denial!) + : LocationFix.ok(lat, lng, accuracy: accuracy); + + @override + Future openSettings() async {} +} diff --git a/lib/data/map_config.dart b/lib/data/map_config.dart new file mode 100644 index 0000000..1186f0f --- /dev/null +++ b/lib/data/map_config.dart @@ -0,0 +1,306 @@ +/// Where map tiles come from, what must be credited, and any key needed to ask +/// for them. +/// +/// This is the **only** place a tile URL, an attribution string or a map API +/// key exists. No widget builds a URL; screens ask for a style and get back +/// whatever the configured provider serves. Swapping CARTO for MapTiler, for a +/// keyed CARTO plan, or for self-hosted tiles is a build flag or one entry in +/// [MapTileProvider.presets] — no map screen changes. +/// +/// Deliberately free of Flutter imports so the choice of provider is testable +/// as plain data. `lib/ui/widgets/map_tiles.dart` turns it into a `TileLayer`. +library; + +/// The two cartographies the app uses. +enum DmMapStyle { + /// Streets, labels and landmarks. The pickup map, where the customer is + /// looking for their own door. + streets, + + /// Quieter, near-monochrome. The route map, where the line is the subject + /// and the basemap is context. + muted, +} + +/// A raster tile source: its URL templates, its credit line, and its key. +class MapTileProvider { + const MapTileProvider({ + required this.id, + required this.name, + required this.templates, + required this.attribution, + this.attributionUrl = osmCopyright, + this.subdomains = const [], + this.keyParam, + this.apiKey = '', + this.requiresKey = false, + this.maxNativeZoom = 20, + this.retina = true, + }); + + /// Stable id, also what `DM_MAP_PROVIDER` selects. + final String id; + + /// Human name, for the "map source" line in diagnostics. + final String name; + + /// One URL template per style. `{s}` `{z}` `{x}` `{y}` `{r}` as flutter_map + /// expects; the key is appended by [urlFor], never written into a template. + final Map templates; + + /// The credit line that must stay visible on every map using this source. + final String attribution; + + /// Where the attribution links, for the licence. + final String attributionUrl; + + /// Tile host shards, when the provider uses them. + final List subdomains; + + /// Query parameter the key travels in — `api_key` for CARTO and Stadia, + /// `key` for MapTiler. Null when the provider takes no key. + final String? keyParam; + + /// The key itself. Supplied at build time, never committed. + final String apiKey; + + /// True when the provider serves nothing without a key, so a missing key is + /// a misconfiguration rather than a downgrade. + final bool requiresKey; + + final int maxNativeZoom; + + /// Whether the provider serves `@2x` tiles for the `{r}` placeholder. + final bool retina; + + static const osmCopyright = 'https://www.openstreetmap.org/copyright'; + + bool get hasKey => apiKey.isNotEmpty; + + /// False when this provider cannot serve a tile as configured. + bool get usable => !requiresKey || hasKey; + + /// The template for [style], with the key appended when there is one. + String urlFor(DmMapStyle style) { + final template = templates[style] ?? templates[DmMapStyle.streets]!; + if (!hasKey || keyParam == null) return template; + final separator = template.contains('?') ? '&' : '?'; + return '$template$separator$keyParam=$apiKey'; + } + + MapTileProvider withKey(String key) => MapTileProvider( + id: id, + name: name, + templates: templates, + attribution: attribution, + attributionUrl: attributionUrl, + subdomains: subdomains, + keyParam: keyParam, + apiKey: key, + requiresKey: requiresKey, + maxNativeZoom: maxNativeZoom, + retina: retina, + ); + + // ------------------------------------------------------------- presets + + /// CARTO's basemaps over OpenStreetMap data. The development default: + /// warm, legible, and usable without an account. + /// + /// Anonymous use is **not** a production contract. CARTO issues API keys for + /// application use — set `DM_MAP_KEY` and it travels as `api_key` on every + /// tile request, no other change required. + static const carto = MapTileProvider( + id: 'carto', + name: 'CARTO basemaps (OpenStreetMap data)', + templates: { + DmMapStyle.streets: + 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', + DmMapStyle.muted: + 'https://{s}.basemaps.cartocdn.com/rastertiles/light_all/{z}/{x}/{y}{r}.png', + }, + attribution: '© OpenStreetMap contributors · © CARTO', + subdomains: ['a', 'b', 'c', 'd'], + keyParam: 'api_key', + ); + + /// OpenStreetMap's own tile servers. + /// + /// Their usage policy does not permit a distributed app to lean on these — + /// present for local development and as a last-resort fallback only. + static const osm = MapTileProvider( + id: 'osm', + name: 'OpenStreetMap standard tiles', + templates: { + DmMapStyle.streets: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + DmMapStyle.muted: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + }, + attribution: '© OpenStreetMap contributors', + maxNativeZoom: 19, + retina: false, + ); + + /// MapTiler — a commercial provider with an India-usable free tier. + static const maptiler = MapTileProvider( + id: 'maptiler', + name: 'MapTiler', + templates: { + DmMapStyle.streets: + 'https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}{r}.png', + DmMapStyle.muted: + 'https://api.maptiler.com/maps/dataviz-light/{z}/{x}/{y}{r}.png', + }, + attribution: '© MapTiler · © OpenStreetMap contributors', + keyParam: 'key', + requiresKey: true, + ); + + /// Stadia Maps, serving OpenStreetMap-derived styles. + static const stadia = MapTileProvider( + id: 'stadia', + name: 'Stadia Maps', + templates: { + DmMapStyle.streets: + 'https://tiles.stadiamaps.com/tiles/osm_bright/{z}/{x}/{y}{r}.png', + DmMapStyle.muted: + 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png', + }, + attribution: '© Stadia Maps · © OpenStreetMap contributors', + keyParam: 'api_key', + requiresKey: true, + ); + + /// Everything that ships with the app, by id. + static const Map presets = { + 'carto': carto, + 'osm': osm, + 'maptiler': maptiler, + 'stadia': stadia, + }; +} + +/// The resolved map configuration for this build. +/// +/// Read once at startup from `--dart-define`s; [instance] is assignable so a +/// test or a staging build can substitute one without touching a screen. +class DmMapConfig { + const DmMapConfig({ + required this.provider, + required this.userAgent, + this.warning, + }); + + final MapTileProvider provider; + + /// Sent as the `User-Agent` on every tile request, so the provider can + /// identify and rate-limit the app properly rather than seeing raw traffic. + final String userAgent; + + /// Set when the requested configuration could not be honoured — surfaced in + /// debug diagnostics rather than silently swallowed. + final String? warning; + + /// Swappable for tests and staging. + static DmMapConfig instance = DmMapConfig.fromEnvironment(); + + /// The bundle id, which is also what identifies us to a tile provider. + static const packageName = 'in.doormile.customer'; + + // ---- build-time configuration ------------------------------------------- + // + // flutter run \ + // --dart-define=DM_MAP_PROVIDER=maptiler \ + // --dart-define=DM_MAP_KEY=xxxxxxxx + // + // flutter build appbundle \ + // --dart-define=DM_MAP_PROVIDER=carto \ + // --dart-define=DM_MAP_KEY=$CARTO_API_KEY + // + // # a fully custom or self-hosted style + // flutter run \ + // --dart-define=DM_MAP_PROVIDER=custom \ + // --dart-define=DM_MAP_URL=https://tiles.doormile.in/streets/{z}/{x}/{y}.png \ + // --dart-define=DM_MAP_URL_MUTED=https://tiles.doormile.in/light/{z}/{x}/{y}.png \ + // --dart-define=DM_MAP_ATTRIBUTION='© Doormile · © OpenStreetMap contributors' + + static const _provider = String.fromEnvironment('DM_MAP_PROVIDER'); + static const _key = String.fromEnvironment('DM_MAP_KEY'); + static const _url = String.fromEnvironment('DM_MAP_URL'); + static const _urlMuted = String.fromEnvironment('DM_MAP_URL_MUTED'); + static const _subdomains = String.fromEnvironment('DM_MAP_SUBDOMAINS'); + static const _attribution = String.fromEnvironment('DM_MAP_ATTRIBUTION'); + static const _attributionUrl = String.fromEnvironment('DM_MAP_ATTRIBUTION_URL'); + static const _maxZoom = int.fromEnvironment('DM_MAP_MAX_ZOOM', defaultValue: 0); + static const _appVersion = String.fromEnvironment( + 'DM_APP_VERSION', + defaultValue: '1.0.0', + ); + + factory DmMapConfig.fromEnvironment() { + final id = _provider.isEmpty ? 'carto' : _provider; + String? warning; + + MapTileProvider provider; + if (id == 'custom') { + if (_url.isEmpty) { + provider = MapTileProvider.carto; + warning = + 'DM_MAP_PROVIDER=custom needs DM_MAP_URL; fell back to CARTO.'; + } else { + provider = MapTileProvider( + id: 'custom', + name: 'Custom tile source', + templates: { + DmMapStyle.streets: _url, + DmMapStyle.muted: _urlMuted.isEmpty ? _url : _urlMuted, + }, + attribution: _attribution.isEmpty + ? '© OpenStreetMap contributors' + : _attribution, + attributionUrl: _attributionUrl.isEmpty + ? MapTileProvider.osmCopyright + : _attributionUrl, + subdomains: _subdomains.isEmpty + ? const [] + : _subdomains.split(',').map((s) => s.trim()).toList(), + maxNativeZoom: _maxZoom == 0 ? 20 : _maxZoom, + retina: _url.contains('{r}'), + ); + } + } else { + provider = MapTileProvider.presets[id] ?? MapTileProvider.carto; + if (!MapTileProvider.presets.containsKey(id)) { + warning = 'Unknown DM_MAP_PROVIDER "$id"; fell back to CARTO.'; + } + } + + if (_key.isNotEmpty) provider = provider.withKey(_key); + + // A keyed provider with no key would render nothing at all. Better a + // downgraded map than a grey rectangle in a customer's hands. + if (!provider.usable) { + warning = + '${provider.name} needs DM_MAP_KEY; fell back to CARTO for this build.'; + provider = MapTileProvider.carto; + if (_key.isNotEmpty) provider = provider.withKey(_key); + } + + return DmMapConfig( + provider: provider, + userAgent: 'Doormile/$_appVersion ($packageName)', + warning: warning, + ); + } + + String urlFor(DmMapStyle style) => provider.urlFor(style); + + String get attribution => provider.attribution; + String get attributionUrl => provider.attributionUrl; + List get subdomains => provider.subdomains; + int get maxNativeZoom => provider.maxNativeZoom; + bool get retina => provider.retina; + + /// One line for a diagnostics screen or a log at startup. + String get describe => + '${provider.name}${provider.hasKey ? ' (keyed)' : ' (anonymous)'}'; +} diff --git a/lib/data/models.dart b/lib/data/models.dart new file mode 100644 index 0000000..cd33a6b --- /dev/null +++ b/lib/data/models.dart @@ -0,0 +1,1227 @@ +/// Domain models for the Doormile customer app. +/// +/// Product model: the customer creates a **pickup booking**, not a shipment. +/// Serviceable state + district are required; the exact address, map pin and +/// recipient details are optional and may be completed by the Miler at pickup. +library; + +/// One journey stage as the customer sees it. Raw backend status keys are +/// mapped onto this enum and never rendered directly. +enum JourneyStage { + booked( + 'booked', + 'Pickup booked', + 'Pickup booked', + "We're finding a Miler near you.", + ), + assigned( + 'assigned', + 'Miler assigned', + 'Miler assigned', + 'Your Miler will arrive in your pickup window.', + ), + onTheWay( + 'on_the_way', + 'Miler on the way', + 'Miler on the way', + 'Keep your package ready.', + ), + arrived( + 'arrived', + 'Miler arrived', + 'Miler has arrived', + 'Hand over the package to complete pickup.', + ), + pickedUp( + 'picked_up', + 'Package picked up', + 'Package picked up', + 'Your Miler has the package.', + ), + orderCreated( + 'order_created', + 'Order created', + 'Order created', + 'Details confirmed. Your package is on its way into our network.', + ), + inTransit( + 'in_transit', + 'In transit', + 'In transit', + 'Moving towards the destination district.', + ), + outForDelivery( + 'out_for_delivery', + 'Out for delivery', + 'Out for delivery', + 'Arriving today at the delivery address.', + ), + delivered( + 'delivered', + 'Delivered', + 'Delivered', + 'Thanks for sending with Doormile.', + ); + + const JourneyStage(this.key, this.label, this.headline, this.sub); + + /// The backend key this stage maps from. + final String key; + + /// Timeline label. + final String label; + + /// Dominant headline on the tracking screen. + final String headline; + + /// Supporting line under the headline. + final String sub; + + int get step => index; + + /// An unrecognised key falls back to [booked] rather than throwing. + /// + /// That is deliberate but it is also silent, so it is worth naming what it + /// hides: seeing "Pickup booked" on a parcel that is plainly moving means the + /// backend shipped a stage this build does not know — a failed-delivery key, + /// most likely, which v1 has no room for. It is a missing client release, not + /// a backend bug. + static JourneyStage fromKey(String? key) => JourneyStage.values.firstWhere( + (s) => s.key == key, + orElse: () => JourneyStage.booked, + ); + + static JourneyStage at(int i) => + JourneyStage.values[i.clamp(0, JourneyStage.values.length - 1)]; + + /// What a status pill should say. Operational stages roll up so the customer + /// never reads "Order created" as their status. + String get milestoneLabel => CustomerMilestone.of(this).label; +} + +/// What the customer sees on the timeline. +/// +/// The backend keeps nine operational states; the customer gets seven +/// meaningful ones. Operational detail ("Miler on the way", "Order created") +/// appears as context under the current milestone rather than as another +/// permanent row. +enum CustomerMilestone { + booked('Pickup booked'), + assigned('Miler assigned'), + pickupInProgress('Pickup in progress'), + collected('Package collected'), + inTransit('In transit'), + outForDelivery('Out for delivery'), + delivered('Delivered'); + + const CustomerMilestone(this.label); + + final String label; + + /// Which milestone an internal stage rolls up into. + static CustomerMilestone of(JourneyStage stage) => switch (stage) { + JourneyStage.booked => CustomerMilestone.booked, + JourneyStage.assigned => CustomerMilestone.assigned, + JourneyStage.onTheWay || JourneyStage.arrived => + CustomerMilestone.pickupInProgress, + JourneyStage.pickedUp || JourneyStage.orderCreated => + CustomerMilestone.collected, + JourneyStage.inTransit => CustomerMilestone.inTransit, + JourneyStage.outForDelivery => CustomerMilestone.outForDelivery, + JourneyStage.delivered => CustomerMilestone.delivered, + }; + + /// The earliest internal stage in this milestone — used for its timestamp. + JourneyStage get firstStage => JourneyStage.values.firstWhere( + (s) => CustomerMilestone.of(s) == this, + ); +} + +enum BookingStatus { + active, + completed, + cancelled; + + /// The backend sends this explicitly rather than making us infer it. An + /// unrecognised value means a status shipped without a client release, and + /// `active` is the safe reading: it keeps the booking visible and tracked + /// rather than filing it away as finished. + static BookingStatus fromKey(String? key) => switch (key) { + 'completed' => BookingStatus.completed, + 'cancelled' || 'canceled' => BookingStatus.cancelled, + _ => BookingStatus.active, + }; +} + +/// Epoch milliseconds, UTC, as every timestamp on this surface is sent. +/// +/// Tolerant of the two other shapes a JSON encoder can produce for the same +/// instant — a numeric string, and an ISO-8601 string with an offset — because +/// the alternative to tolerating them is a crash on the tracking screen. A +/// naive local string with no offset is deliberately **not** accepted: it is +/// ambiguous by 5h30m here, and guessing is how "yesterday's work shown as +/// today" happened on the Miler app. +DateTime? epochMillis(dynamic value) { + if (value == null) return null; + if (value is int) return DateTime.fromMillisecondsSinceEpoch(value); + if (value is num) return DateTime.fromMillisecondsSinceEpoch(value.toInt()); + if (value is String) { + final asInt = int.tryParse(value); + if (asInt != null) return DateTime.fromMillisecondsSinceEpoch(asInt); + final parsed = DateTime.tryParse(value); + if (parsed != null && (value.contains('+') || value.endsWith('Z'))) { + return parsed.toLocal(); + } + } + return null; +} + +/// Reads a coordinate that the wire may spell either way. +/// +/// The contract documents `latitude`/`longitude` on every payload that carries +/// a point — the booking's pickup, each destination, the fare's pickup — so +/// that is what the client sends. It still accepts the short form on the way +/// in: the district catalogue and the saved-locations rows use it, and a +/// booking that silently loses its pin is a tracking screen with no map on it. +double? coordinate(Map json, String key) { + final long = key == 'lat' ? 'latitude' : 'longitude'; + final value = json[long] ?? json[key]; + return (value as num?)?.toDouble(); +} + +/// What `POST /customer/auth/otp/request` answers with. +/// +/// The server owns both numbers, and the OTP screen is built from them rather +/// than from constants: a backend that moves to a six-digit code, or lengthens +/// the resend window, must not need an app release to be usable. +class OtpChallenge { + const OtpChallenge({ + this.codeLength = 4, + this.resendAfterSeconds = 30, + this.sent = true, + }); + + /// How many digits the code has. + final int codeLength; + + /// How long before "Resend code" is offered again. + final int resendAfterSeconds; + + /// False when the server accepted the request but did not deliver a code — + /// worth saying out loud rather than leaving somebody waiting for an SMS + /// that is not coming. + final bool sent; + + /// The shape to assume when the server did not describe the challenge. + static const OtpChallenge unspecified = OtpChallenge(); + + factory OtpChallenge.fromJson(Map json) => OtpChallenge( + // Clamped to what the screen can lay out: a server answering 40 must not + // produce a row of boxes narrower than a finger. + codeLength: ((json['codeLength'] as num?)?.toInt() ?? 4).clamp(4, 8), + resendAfterSeconds: + ((json['resendAfterSeconds'] as num?)?.toInt() ?? 30).clamp(0, 600), + sent: json['sent'] as bool? ?? true, + ); +} + +/// A serviceable state, as returned by `GET /serviceability/states`. +class ServiceArea { + const ServiceArea({ + required this.code, + required this.name, + required this.districtCount, + this.transitTag, + }); + + final String code; + final String name; + final int districtCount; + + /// Short promise shown beside the state picker, e.g. "Ultra-fast transit". + final String? transitTag; + + bool get hasOpenDistricts => districtCount > 0; + + factory ServiceArea.fromJson(Map json) => ServiceArea( + code: json['code'] as String, + name: json['name'] as String, + districtCount: json['districtCount'] as int? ?? 0, + transitTag: json['transitTag'] as String?, + ); +} + +/// A district inside a serviceable state. +class District { + const District({ + required this.code, + required this.name, + required this.available, + this.note, + this.hub, + this.promise, + this.lat, + this.lng, + }); + + final String code; + final String name; + final bool available; + final String? note; + + /// Delivery hub serving this district, e.g. "Coimbatore Central Hub". + final String? hub; + + /// What that hub guarantees, e.g. "Next-day delivery". + final String? promise; + + /// Where that hub sits, so the route map can draw a real line to it. + /// Backend-served; null simply means no line is drawn. + final double? lat; + final double? lng; + + bool get hasLocation => lat != null && lng != null; + + factory District.fromJson(Map json) => District( + code: json['code'] as String, + name: json['name'] as String, + available: json['available'] as bool? ?? true, + note: json['note'] as String?, + hub: json['hub'] as String?, + promise: json['promise'] as String?, + lat: (json['lat'] as num?)?.toDouble(), + lng: (json['lng'] as num?)?.toDouble(), + ); +} + +/// A pickup window offered by the backend. +class PickupSlot { + const PickupSlot({ + required this.id, + required this.day, + required this.window, + required this.available, + this.note, + this.tag, + this.milersNearby = 0, + this.caption, + }); + + final String id; + final String day; + final String window; + final bool available; + final String? note; + + /// Highlight badge, e.g. "Fastest pickup". + final String? tag; + + /// How many Milers are currently in the zone for this window. + final int milersNearby; + + /// One-line description, e.g. "Arriving in approx. 45 mins". + final String? caption; + + /// "Today · 2:00 – 4:00 PM" + String get label => '$day · $window'; + + factory PickupSlot.fromJson(Map json) => PickupSlot( + id: json['id'] as String, + day: json['day'] as String, + window: json['window'] as String, + available: json['available'] as bool? ?? true, + note: json['note'] as String?, + tag: json['tag'] as String?, + milersNearby: json['milersNearby'] as int? ?? 0, + caption: json['caption'] as String?, + ); +} + +/// A pickup or delivery place. +class Place { + const Place({required this.title, required this.sub, this.lat, this.lng}); + + final String title; + final String sub; + final double? lat; + final double? lng; + + /// True when this place can be shown on a map. + bool get hasLocation => lat != null && lng != null; + + /// The contract guarantees `title` and `sub`; this parser still does not + /// insist on them. A pickup that renders with a thin label is a cosmetic + /// problem — a booking detail that throws is a customer who cannot see their + /// parcel at all, and staging has not been proven yet. + factory Place.fromJson(Map json) => Place( + title: (json['title'] as String?)?.trim().isNotEmpty == true + ? json['title'] as String + : 'Pickup point', + sub: (json['sub'] as String?) ?? '', + lat: coordinate(json, 'lat'), + lng: coordinate(json, 'lng'), + ); + + Map toJson() => { + 'title': title, + 'sub': sub, + 'latitude': ?lat, + 'longitude': ?lng, + }; + + /// `POST /customer/bookings` ▸ `pickup`. + /// + /// Carries who the Miler asks for at the door. That is the signed-in + /// customer unless the caller names somebody else, and it is sent rather + /// than left to the server to look up, because the contract asks for it. + Map toBookingJson({ + String? contactName, + String? contactPhone, + }) => { + ...toJson(), + 'contactName': ?contactName, + 'contactPhone': ?contactPhone, + }; +} + +/// The only required destination information: a serviceable state + district. +class Destination { + Destination({ + this.stateCode, + this.stateName, + this.districtCode, + this.districtName, + }); + + String? stateCode; + String? stateName; + String? districtCode; + String? districtName; + + /// Whether this destination can be **booked**. The codes are what the + /// booking request carries, so the form gates on them. + bool get isComplete => stateCode != null && districtCode != null; + + /// "Coimbatore, Tamil Nadu". + /// + /// Read from the **names**, and deliberately not gated on [isComplete]: the + /// booking response names the state and district and sends no codes back at + /// all, so gating this on the codes rendered every read-back destination in + /// the app — Orders rows, tracking, the receipt — as an em dash. + String get label { + final named = [?_clean(districtName), ?_clean(stateName)]; + if (named.isNotEmpty) return named.join(', '); + final coded = [?_clean(districtCode), ?_clean(stateCode)]; + return coded.isEmpty ? '—' : coded.join(', '); + } + + static String? _clean(String? v) { + final t = v?.trim(); + return (t == null || t.isEmpty) ? null : t; + } + + Destination copy() => Destination( + stateCode: stateCode, + stateName: stateName, + districtCode: districtCode, + districtName: districtName, + ); + + /// `stateName` and `districtName` are contract-guaranteed, because the client + /// renders "Chennai, Tamil Nadu" from them and never looks a code up. + factory Destination.fromJson(Map json) => Destination( + stateCode: json['stateCode'] as String?, + stateName: json['stateName'] as String?, + districtCode: json['districtCode'] as String?, + districtName: json['districtName'] as String?, + ); + + /// Only the codes are sent back — the server owns the names. + Map toJson() => { + 'stateCode': ?stateCode, + 'districtCode': ?districtCode, + }; +} + +class MapPin { + const MapPin(this.lat, this.lng); + final double lat; + final double lng; + + static MapPin? fromJson(Map? json) { + if (json == null) return null; + final lat = coordinate(json, 'lat'); + final lng = coordinate(json, 'lng'); + if (lat == null || lng == null) return null; + return MapPin(lat, lng); + } + + Map toJson() => {'latitude': lat, 'longitude': lng}; +} + +/// Everything here is optional at booking time. The Miler fills the gaps +/// during pickup, which is why nothing in this class is required. +class DeliveryDetails { + DeliveryDetails({ + this.street, + this.building, + this.landmark, + this.recipientName, + this.recipientPhone, + this.instructions, + this.pin, + }); + + String? street; + String? building; + String? landmark; + String? recipientName; + String? recipientPhone; + String? instructions; + MapPin? pin; + + static String? _clean(String? v) { + final t = v?.trim(); + return (t == null || t.isEmpty) ? null : t; + } + + void update({ + String? street, + String? building, + String? landmark, + String? recipientName, + String? recipientPhone, + String? instructions, + }) { + this.street = _clean(street); + this.building = _clean(building); + this.landmark = _clean(landmark); + this.recipientName = _clean(recipientName); + this.recipientPhone = _clean(recipientPhone); + this.instructions = _clean(instructions); + } + + /// "3B, 4th Street, Near bus stand, Map pin dropped" — null when nothing set. + String? get exactAddress { + final parts = [ + ?building, + ?street, + ?landmark, + if (pin != null) 'Map pin dropped', + ]; + return parts.isEmpty ? null : parts.join(', '); + } + + /// "Meera S · 9884412210" — null when nothing set. + String? get recipient { + final parts = [?recipientName, ?recipientPhone]; + return parts.isEmpty ? null : parts.join(' · '); + } + + DeliveryDetails copy() => DeliveryDetails( + street: street, + building: building, + landmark: landmark, + recipientName: recipientName, + recipientPhone: recipientPhone, + instructions: instructions, + pin: pin, + ); + + /// Reads the details from either shape the wire uses. + /// + /// The contract carries them **flat on the destination** — `recipientName`, + /// `building`, `street`, `landmark`, `latitude`, `longitude` — so the caller + /// hands this the destination row itself when there is no nested `details` + /// object, and the pin is then read off the row's own coordinates. + factory DeliveryDetails.fromJson(Map? json) { + if (json == null) return DeliveryDetails(); + return DeliveryDetails( + street: _clean(json['street'] as String?), + building: _clean(json['building'] as String?), + landmark: _clean(json['landmark'] as String?), + recipientName: _clean(json['recipientName'] as String?), + recipientPhone: _clean(json['recipientPhone'] as String?), + instructions: _clean(json['instructions'] as String?), + pin: + MapPin.fromJson(json['pin'] as Map?) ?? + MapPin.fromJson(json), + ); + } + + /// Empty when nothing was filled in, so a booking request omits `details` + /// entirely rather than sending a map of nulls. + bool get isEmpty => + street == null && + building == null && + landmark == null && + recipientName == null && + recipientPhone == null && + instructions == null && + pin == null; + + /// The recipient and address fields exactly as a booking's destination + /// carries them. + /// + /// [instructions] is deliberately absent: the contract has no per-destination + /// note, it has one `remarks` line for the whole visit, and that is where + /// [LiveDoormileApi] sends it. Repeating it here would put the same sentence + /// on the wire twice under a key the server does not read. + Map toJson() => { + 'recipientName': ?recipientName, + 'recipientPhone': ?recipientPhone, + 'building': ?building, + 'street': ?street, + 'landmark': ?landmark, + 'latitude': ?pin?.lat, + 'longitude': ?pin?.lng, + }; + + /// `PATCH /customer/bookings/{reference}/destinations/{index}`. + /// + /// Flat and in the destination's own vocabulary, like every other place the + /// contract carries a recipient. A `null` **clears** the field rather than + /// being omitted, so every key is sent whether or not it has a value — + /// otherwise a customer could add a landmark but never remove one. + Map toPatchJson() => { + 'recipientName': recipientName, + 'recipientPhone': recipientPhone, + 'building': building, + 'street': street, + 'landmark': landmark, + 'instructions': instructions, + 'latitude': pin?.lat, + 'longitude': pin?.lng, + }; +} + +/// A Miler (pickup) or delivery agent. +class Person { + const Person({ + required this.name, + required this.vehicle, + required this.phone, + this.rating, + this.trips, + this.vehicleType, + }); + + final String name; + final String vehicle; + final String phone; + + /// Customer rating out of 5, when the backend sent one. **Null is not 4.9** — + /// a default here would put an invented reputation on a real person's card. + final double? rating; + + /// Completed pickups, for the "1240+ pickups" line. Null when unknown. + final int? trips; + + /// "E-Scooter". Null when the backend did not say. + final String? vehicleType; + + /// "4.9 · 1240+ pickups", or null when there is nothing true to say. + String? get credentials { + final parts = [ + if (rating != null) '$rating', + if (trips != null && trips! > 0) '$trips+ pickups', + ]; + return parts.isEmpty ? null : parts.join(' · '); + } + + String get initials { + final parts = name.trim().split(RegExp(r'\s+')); + final letters = parts.map((p) => p.isEmpty ? '' : p[0]).join(); + return letters + .substring(0, letters.length < 2 ? letters.length : 2) + .toUpperCase(); + } + + /// Null rather than a blank card: a Miler with no name is not a Miler the + /// customer should be shown waiting for. + static Person? fromJson(Map? json) { + if (json == null) return null; + final name = (json['name'] as String?)?.trim(); + if (name == null || name.isEmpty) return null; + return Person( + name: name, + vehicle: (json['vehicle'] as String?) ?? '', + phone: (json['phone'] as String?) ?? '', + rating: (json['rating'] as num?)?.toDouble(), + trips: (json['trips'] as num?)?.toInt(), + vehicleType: (json['vehicleType'] as String?), + ); + } +} + +/// Caps on a single pickup, served by the backend so they can change without +/// an app release. Nothing in the UI hardcodes these numbers. +/// +/// ── Why the default is 1 and not 5 ── +/// +/// Multi-destination is server-gated: a pickup that fans out into several +/// orders is undeliverable until the Miler build keys its work on +/// `consignmentid`, so the backend advertises `maxDestinations: 1` until then. +/// A client that fell back to 5 when the config call failed would hand a +/// customer a booking the network cannot complete — a stranded parcel bought +/// with one dropped request. The safe cap is the low one, so that is the +/// default. See `GET /customer/config/booking-limits`. +class BookingLimits { + const BookingLimits({ + this.maxPackages = 20, + this.maxDestinations = 1, + this.maxCodAmount = 0, + }); + + final int maxPackages; + final int maxDestinations; + + /// The largest cash-on-delivery amount a destination may carry, in whole + /// rupees. **Zero means the server is not offering COD**, which is also the + /// default: this app has no COD entry field yet, so the cap is read and + /// carried rather than assumed. See [allowsCod]. + final int maxCodAmount; + + /// Clamped to at least 1: a server answering `0` must not leave the customer + /// with a booking form that can never be completed. + factory BookingLimits.fromJson(Map json) => BookingLimits( + maxPackages: ((json['maxPackages'] as num?)?.toInt() ?? 20).clamp(1, 999), + maxDestinations: ((json['maxDestinations'] as num?)?.toInt() ?? 1).clamp( + 1, + 999, + ), + maxCodAmount: ((json['maxCodAmount'] as num?)?.toInt() ?? 0).clamp( + 0, + 1000000, + ), + ); + + /// True when the backend will accept a `codAmount` at all. + bool get allowsCod => maxCodAmount > 0; + + /// True when the customer may send to more than one place at all. Drives + /// whether "Send to another place" is offered — a permanently disabled + /// control is worse than an absent one. + bool get allowsMultipleDestinations => maxDestinations > 1; +} + +/// What the Miler recorded at the door. +/// +/// Weight and photographs are captured during pickup verification, which is +/// also when the price settles — so this is the customer's evidence of exactly +/// what was collected. +class ParcelVerification { + const ParcelVerification({ + required this.weightKg, + required this.photos, + required this.capturedAt, + this.capturedBy, + }); + + final double weightKg; + + /// One reference per package. A URL once photos are stored remotely. + final List photos; + + final DateTime capturedAt; + + /// The Miler who weighed and photographed it. + final String? capturedBy; + + String get weightLabel => '${weightKg.toStringAsFixed(1)} kg'; + + /// Null when the parcel has not been weighed yet. A verification block with + /// no weight is not evidence, so it is not shown as any. + static ParcelVerification? fromJson(Map? json) { + if (json == null) return null; + final weight = (json['weightKg'] as num?)?.toDouble(); + if (weight == null) return null; + return ParcelVerification( + weightKg: weight, + photos: (json['photos'] as List? ?? const []) + .whereType() + .toList(), + capturedAt: epochMillis(json['capturedAt']) ?? DateTime.now(), + capturedBy: json['capturedBy'] as String?, + ); + } +} + +/// One place the customer is sending packages to, inside a pickup booking. +/// +/// State and district are the only required parts. Everything else is either +/// optional at booking time or filled in by the backend after collection: a +/// group becomes its own tracked order once the Miler completes the pickup. +class DestinationGroup { + DestinationGroup({ + Destination? destination, + this.packageCount = 1, + DeliveryDetails? details, + this.trackingId, + this.stage, + this.index, + this.codAmount, + }) : destination = destination ?? Destination(), + details = details ?? DeliveryDetails(); + + final Destination destination; + + /// The server's position for this stop, which is what + /// `PATCH …/destinations/{index}` addresses. Null on a draft that has not + /// been booked yet, where the list position is the only ordering there is. + int? index; + + /// Cash to collect from the recipient, in whole rupees. Null when there is + /// none — and there never is yet, because no screen offers to set one. It is + /// parsed and echoed back so a booking created elsewhere does not lose it. + int? codAmount; + + /// How many packages go to this place. Never shown as "pieces" or "MPS". + int packageCount; + + /// Optional exact address and recipient. + DeliveryDetails details; + + /// Assigned only after the backend creates the order at pickup. + String? trackingId; + + /// Its own journey, once collected. Null until the order exists. + JourneyStage? stage; + + /// Weight and photos recorded by the Miler at pickup. Null until collected. + ParcelVerification? verification; + + /// The hub serving this district, when the backend told us. + District? district; + + bool get isComplete => destination.isComplete; + + /// The serving hub as something a map can draw. Null until the backend has + /// told us where the district is, in which case no line is drawn. + Place? get hub => district != null && district!.hasLocation + ? Place( + title: district!.hub ?? destination.districtName ?? 'Destination', + sub: district!.promise ?? destination.label, + lat: district!.lat, + lng: district!.lng, + ) + : null; + + DestinationGroup copy() => + DestinationGroup( + destination: destination.copy(), + packageCount: packageCount, + details: details.copy(), + trackingId: trackingId, + stage: stage, + index: index, + codAmount: codAmount, + ) + ..district = district + ..verification = verification; + + /// `trackingId` and `stage` are null until `order_created` — the contract + /// guarantees it, and it is what tells the UI there is no order to track yet. + factory DestinationGroup.fromJson(Map json) => + DestinationGroup( + destination: Destination.fromJson(json), + packageCount: (json['packageCount'] as num?)?.toInt() ?? 1, + // The contract sends the recipient and address flat on the row; a + // nested `details` object is accepted too and wins where both exist. + details: DeliveryDetails.fromJson( + json['details'] as Map? ?? json, + ), + trackingId: json['trackingId'] as String?, + stage: json['stage'] == null + ? null + : JourneyStage.fromKey(json['stage'] as String?), + index: (json['index'] as num?)?.toInt(), + codAmount: (json['codAmount'] as num?)?.toInt(), + ) + ..district = json['district'] is Map + ? District.fromJson(json['district'] as Map) + : null + ..verification = ParcelVerification.fromJson( + json['verification'] as Map?, + ); + + /// One entry of `POST /customer/bookings` ▸ `destinations`. + /// + /// Flat, as the contract asks: the recipient and the address sit beside the + /// codes rather than under a `details` object, and the dropped pin is spelled + /// `latitude`/`longitude`. Anything the customer left blank is omitted — + /// that is the "Not added" state the Miler completes at the door, and it is + /// not the same as sending an empty string. + Map toBookingJson() => { + ...destination.toJson(), + 'packageCount': packageCount, + ...details.toJson(), + 'codAmount': ?codAmount, + }; +} + +/// Indicative price for a booking, confirmed at pickup once the Miler weighs +/// and measures the parcel. +class FareEstimate { + const FareEstimate({ + required this.min, + required this.max, + this.paymentMethod, + this.parcel, + this.routeKm, + }); + + final int min; + final int max; + + /// How the customer will pay. Not in the contract's estimate response, so it + /// is usually null and the line that renders it is simply absent. + final String? paymentMethod; + + /// What is being priced, in the customer's words. Null when unknown — the + /// receipt drops the row rather than claiming a "Standard box". + final String? parcel; + + /// Road distance the estimate was priced over, when the server sent one. + /// `POST /customer/fare/estimate` returns it as `routeKm`. + final double? routeKm; + + String get range => '₹$min – ₹$max'; + + /// Money is whole rupees on this surface, never paise. + /// + /// The band is spelled `minRupees`/`maxRupees` by the contract; the short + /// form is still read because the booking object carries its stored estimate + /// under `fare` and has always used it. + static FareEstimate? fromJson(Map? json) { + if (json == null) return null; + final min = ((json['minRupees'] ?? json['min']) as num?)?.toInt(); + final max = ((json['maxRupees'] ?? json['max']) as num?)?.toInt(); + if (min == null || max == null) return null; + return FareEstimate( + min: min, + max: max, + paymentMethod: (json['paymentMethod'] as String?), + parcel: (json['parcel'] as String?), + routeKm: (json['routeKm'] as num?)?.toDouble(), + ); + } +} + +/// One entry in the tracking timeline. +class StageEvent { + const StageEvent(this.stage, this.at); + final JourneyStage stage; + final DateTime at; + + /// Skips an entry with no usable timestamp: the timeline's whole job is to + /// say *when*, and an invented time is worse than a missing row. + static StageEvent? fromJson(Map json) { + final at = epochMillis(json['at']); + if (at == null) return null; + return StageEvent(JourneyStage.fromKey(json['stage'] as String?), at); + } +} + +/// A pickup booking, which the backend converts into an order once the Miler +/// completes pickup. `trackingId` therefore stays null until [orderCreated]. +class Booking { + Booking({ + required this.reference, + required this.pickup, + required this.destinations, + required this.slotId, + required this.createdAt, + this.stage = JourneyStage.booked, + this.status = BookingStatus.active, + this.cancellable = true, + this.miler, + this.deliveryAgent, + this.expectedDelivery, + this.cancelReason, + this.fare, + this.amountPaid, + this.deliveredAt, + this.routeKm, + this.milersInZone = 0, + this.milerDistanceKm, + this.milerEtaMinutes, + List? history, + }) : history = history ?? []; + + final String reference; + JourneyStage stage; + BookingStatus status; + bool cancellable; + final DateTime createdAt; + + Place pickup; + + /// Where the packages are going. One entry is the common case; a pickup can + /// carry several, all collected in the same Miler visit. + List destinations; + + String slotId; + + // ---- convenience over [destinations] ------------------------------------- + + bool get isMultiDestination => destinations.length > 1; + + int get totalPackages => + destinations.fold(0, (sum, group) => sum + group.packageCount); + + /// The first destination — what a single-destination booking is "about". + Destination get destination => destinations.first.destination; + + /// Optional details for the first destination. + DeliveryDetails get details => destinations.first.details; + + /// Only a single-destination booking has one tracking number of its own; + /// a multi-destination pickup is identified by its reference. + String? get trackingId => + destinations.length == 1 ? destinations.first.trackingId : null; + + /// True once the backend has turned the pickup into order(s). + bool get hasOrders => destinations.any((d) => d.trackingId != null); + + /// Total verified weight across every destination, once weighed at pickup. + String? get totalWeightLabel { + final weighed = destinations + .where((d) => d.verification != null) + .map((d) => d.verification!.weightKg); + if (weighed.isEmpty) return null; + final total = weighed.reduce((a, b) => a + b); + return '${total.toStringAsFixed(1)} kg'; + } + + /// "3 packages · 2 destinations", or null when there is nothing to add up. + String? get packageSummary { + final packages = totalPackages; + if (packages <= 1 && destinations.length <= 1) return null; + final p = '$packages ${packages == 1 ? 'package' : 'packages'}'; + if (destinations.length == 1) return p; + return '$p · ${destinations.length} destinations'; + } + + Person? miler; + Person? deliveryAgent; + String? expectedDelivery; + String? cancelReason; + + /// Indicative fare shown at review and on the booking. + FareEstimate? fare; + + /// What was actually charged, known once the parcel is weighed at pickup. + int? amountPaid; + + /// When it reached the recipient. + DateTime? deliveredAt; + + /// Pickup-to-destination distance, for the route outline. Null when the + /// backend has not measured it; the receipt drops the row rather than + /// printing "0.0 km". + double? routeKm; + + /// Verified Milers active in the pickup zone, shown while assigning. Zero + /// means "the backend did not say", and the line is not shown. + int milersInZone; + + /// How far the Miler is from the door, once they are on the way. + double? milerDistanceKm; + + /// Minutes until the Miler reaches the door. + int? milerEtaMinutes; + + List history; + + /// What the customer sees as the identifier for this pickup. + String get displayId => trackingId ?? reference; + + bool get isCancelled => status == BookingStatus.cancelled; + + DateTime? timeOf(JourneyStage s) { + for (final e in history) { + if (e.stage == s) return e.at; + } + return null; + } + + /// The canonical booking object — `GET /customer/bookings/{reference}`. + /// + /// Everything the tracking screen and the receipt render comes from here, so + /// this parser reads the whole shape rather than the subset the prototype + /// needed. Two rules it follows throughout: + /// + /// * **Absent is not zero.** `amountPaid`, `deliveredAt`, `verification`, + /// `trackingId` and the per-destination `stage` are null before the events + /// that create them, and the UI distinguishes "not yet" from "none". + /// * **A malformed member costs its own field, not the booking.** Only + /// `reference` is required; a booking that throws is a customer who cannot + /// see their parcel at all, and staging is not proven yet. + factory Booking.fromJson(Map json) { + final rawDestinations = (json['destinations'] as List? ?? const []) + .whereType>() + .map(DestinationGroup.fromJson) + .toList(); + // `index` is what `PATCH …/destinations/{index}` addresses. The contract + // sends it; falling back to the list position keeps an older payload + // addressable rather than leaving the details sheet with nowhere to write. + for (var i = 0; i < rawDestinations.length; i++) { + rawDestinations[i].index ??= i; + } + + final fare = FareEstimate.fromJson(json['fare'] as Map?); + + final history = + (json['history'] as List? ?? const []) + .whereType>() + .map(StageEvent.fromJson) + .whereType() + .toList() + ..sort((a, b) => a.at.compareTo(b.at)); + + return Booking( + reference: json['reference'] as String, + stage: JourneyStage.fromKey(json['stage'] as String?), + status: BookingStatus.fromKey(json['status'] as String?), + cancellable: json['cancellable'] as bool? ?? false, + createdAt: epochMillis(json['createdAt']) ?? DateTime.now(), + pickup: json['pickup'] is Map + ? Place.fromJson(json['pickup'] as Map) + : const Place(title: 'Pickup point', sub: ''), + // A booking with no destinations cannot be rendered as a journey, but it + // can still be listed. One empty group keeps every `destinations.first` + // in the UI safe. + destinations: rawDestinations.isEmpty + ? [DestinationGroup()] + : rawDestinations, + slotId: (json['slotId'] as String?) ?? '', + miler: Person.fromJson(json['miler'] as Map?), + deliveryAgent: Person.fromJson( + json['deliveryAgent'] as Map?, + ), + expectedDelivery: json['expectedDelivery'] as String?, + cancelReason: json['cancelReason'] as String?, + fare: fare, + amountPaid: (json['amountPaid'] as num?)?.toInt(), + deliveredAt: epochMillis(json['deliveredAt']), + // The fare carries the distance it was priced over, so a booking that + // does not repeat `routeKm` at the top level still shows a real one. + routeKm: (json['routeKm'] as num?)?.toDouble() ?? fare?.routeKm, + milersInZone: (json['milersInZone'] as num?)?.toInt() ?? 0, + milerDistanceKm: (json['milerDistanceKm'] as num?)?.toDouble(), + milerEtaMinutes: (json['milerEtaMinutes'] as num?)?.toInt(), + history: history, + ); + } +} + +/// One keyset page of bookings. +/// +/// Keyset, not offset: a booking landing at the top mid-scroll must not push a +/// row the customer has already seen onto the next page. +class BookingPage { + const BookingPage({ + required this.bookings, + this.nextCursor, + this.total, + }); + + final List bookings; + + /// Cursor for the next page; null on the last one. + final String? nextCursor; + + /// Rows behind the filtered tab, when the server counted them. + final int? total; + + bool get hasMore => nextCursor != null && nextCursor!.isNotEmpty; + + static const BookingPage empty = BookingPage(bookings: []); +} + +/// A place the customer saved — `GET /customer/locations`. +class SavedPlace { + const SavedPlace({required this.id, required this.place, this.label}); + + final String id; + final Place place; + + /// "Home", "Office" — the customer's own name for it. + final String? label; + + factory SavedPlace.fromJson(Map json) => SavedPlace( + id: (json['id'] ?? '').toString(), + place: Place.fromJson(json), + label: json['label'] as String?, + ); + + Map toJson() => {...place.toJson(), 'label': ?label}; +} + +/// A row in Orders. +/// +/// Before collection a booking is one pickup. Once the Miler has verified it +/// and the backend has created the orders, each destination stands on its own +/// with its own tracking number and journey — so it gets its own row, with a +/// quiet line back to the pickup they shared. +class OrderEntry { + const OrderEntry(this.booking, [this.destinationIndex]); + + final Booking booking; + + /// Null for the pickup itself. + final int? destinationIndex; + + bool get isPickup => destinationIndex == null; + + DestinationGroup? get group => + destinationIndex == null ? null : booking.destinations[destinationIndex!]; + + /// True when this order shared a Miler visit with others. + bool get collectedTogether => !isPickup && booking.isMultiDestination; + + /// Sort key so a split pickup keeps its rows together and in order. + DateTime get createdAt => booking.createdAt; +} + +/// The signed-in customer. +class Customer { + const Customer({ + required this.id, + required this.name, + required this.phone, + required this.email, + }); + + final String id; + final String name; + final String phone; + final String email; + + String get initials { + final parts = name.trim().split(RegExp(r'\s+')); + final letters = parts.map((p) => p.isEmpty ? '' : p[0]).join(); + return letters + .substring(0, letters.length < 2 ? letters.length : 2) + .toUpperCase(); + } + + /// `email` is typed non-nullable here and the contract promises `""` rather + /// than `null` for an unknown one — but a missing key still resolves to `""` + /// rather than throwing, because Account renders initials from `name` and + /// would lose the whole screen over a field it does not use. + factory Customer.fromJson(Map json) => Customer( + id: (json['id'] ?? '').toString(), + name: (json['name'] as String?) ?? '', + phone: (json['phone'] as String?) ?? '', + email: (json['email'] as String?) ?? '', + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'phone': phone, + 'email': email, + }; +} diff --git a/lib/data/session_store.dart b/lib/data/session_store.dart new file mode 100644 index 0000000..5421f3a --- /dev/null +++ b/lib/data/session_store.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'models.dart'; + +/// One signed-in session: the two tokens, when the access token dies, and who +/// the customer is. +@immutable +class Session { + const Session({ + required this.accessToken, + required this.refreshToken, + required this.expiresAt, + this.customer, + }); + + final String accessToken; + final String refreshToken; + + /// Absolute expiry, computed from the `expiresIn` the server sent. + final DateTime expiresAt; + + final Customer? customer; + + /// Treated as expired a minute early, so a call started just before the + /// boundary does not land just after it. + bool get isExpired => + DateTime.now().isAfter(expiresAt.subtract(const Duration(minutes: 1))); + + Session copyWith({ + String? accessToken, + String? refreshToken, + DateTime? expiresAt, + Customer? customer, + }) => Session( + accessToken: accessToken ?? this.accessToken, + refreshToken: refreshToken ?? this.refreshToken, + expiresAt: expiresAt ?? this.expiresAt, + customer: customer ?? this.customer, + ); + + Map toJson() => { + 'accessToken': accessToken, + 'refreshToken': refreshToken, + 'expiresAt': expiresAt.millisecondsSinceEpoch, + if (customer != null) 'customer': customer!.toJson(), + }; + + static Session? fromJson(Map json) { + final access = json['accessToken'] as String?; + final refresh = json['refreshToken'] as String?; + final expires = json['expiresAt']; + if (access == null || access.isEmpty) return null; + if (refresh == null || refresh.isEmpty) return null; + return Session( + accessToken: access, + refreshToken: refresh, + expiresAt: expires is int + ? DateTime.fromMillisecondsSinceEpoch(expires) + : DateTime.now(), + customer: json['customer'] is Map + ? Customer.fromJson(json['customer'] as Map) + : null, + ); + } +} + +/// Where the session lives between launches. +/// +/// The refresh token is valid for 60 days and **rotates on every use** — a +/// replayed one revokes the whole chain server-side. So the two rules this +/// class exists to enforce are: persist the newest token the moment it arrives, +/// and never hand out a half-written session. +abstract class SessionStore { + Future read(); + Future write(Session session); + Future clear(); +} + +/// Keychain on iOS, EncryptedSharedPreferences on Android. +class SecureSessionStore implements SessionStore { + SecureSessionStore({FlutterSecureStorage? storage}) + // Keychain on iOS by default; Android needs asking, or it falls back to a + // plaintext preferences file — which is the one place a 60-day refresh + // token must never sit. + : _storage = + storage ?? + const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + ); + + final FlutterSecureStorage _storage; + + static const String _key = 'dm_cx_session_v1'; + + @override + Future read() async { + try { + final raw = await _storage.read(key: _key); + if (raw == null || raw.isEmpty) return null; + final decoded = jsonDecode(raw); + if (decoded is! Map) return null; + return Session.fromJson(decoded); + } catch (e) { + // A session we cannot read is a session we do not have. Signing the + // customer out is recoverable; crashing at launch is not. + debugPrint('[SESSION] unreadable, treating as signed out: $e'); + return null; + } + } + + @override + Future write(Session session) async { + try { + await _storage.write(key: _key, value: jsonEncode(session.toJson())); + } catch (e) { + // Losing persistence costs the customer a re-login next launch. It must + // not cost them the sign-in they just completed. + debugPrint('[SESSION] could not persist: $e'); + } + } + + @override + Future clear() async { + try { + await _storage.delete(key: _key); + } catch (e) { + debugPrint('[SESSION] could not clear: $e'); + } + } +} + +/// In-memory store. Tests only — a session that does not survive the process +/// is not a session. +class MemorySessionStore implements SessionStore { + Session? _session; + + @override + Future read() async => _session; + + @override + Future write(Session session) async => _session = session; + + @override + Future clear() async => _session = null; +} diff --git a/lib/features/auth/screens/login_screen.dart b/lib/features/auth/screens/login_screen.dart deleted file mode 100644 index ccbad2a..0000000 --- a/lib/features/auth/screens/login_screen.dart +++ /dev/null @@ -1,354 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/gestures.dart'; -import 'package:provider/provider.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/utils/snackbar_utils.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; -import 'package:doormile/core/widgets/logo.dart'; - -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); - - @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - final _phone = TextEditingController(); - bool _loading = false; - - bool get _valid => _phone.text.replaceAll(' ', '').length == 10; - - void _login() async { - if (!_valid) return; - FocusScope.of(context).unfocus(); - setState(() => _loading = true); - - final state = context.read(); - try { - await state.initiateLogin(_phone.text); - if (!mounted) return; - - // Backend already gates login behind the PIN check, so no separate - // OTP possession-of-phone step is needed for returning users. - setState(() => _loading = false); - Navigator.pushNamed(context, Routes.enterPin); - } catch (e) { - if (!mounted) return; - setState(() => _loading = false); - SnackbarUtils.showError(context, e.toString()); - } - } - - @override - void dispose() { - _phone.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final size = MediaQuery.of(context).size; - final width = size.width; - final height = size.height; - - return Scaffold( - body: Container( - width: double.infinity, - height: double.infinity, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - AppColors.primary.withValues(alpha: 0.05), - Colors.white, - AppColors.tertiary.withValues(alpha: 0.05), - ], - ), - ), - child: SafeArea( - child: SingleChildScrollView( - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: width * 0.05, - vertical: height * 0.02, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox(height: height * 0.08), - const DoormileMark(size: 140), - SizedBox(height: height * 0.04), - Text( - "Sign In", - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - color: AppColors.primary, - fontFamily: AppTheme.fontFamily, - ), - ), - SizedBox(height: height * 0.02), - Text( - "Enter your mobile number to get started.", - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.black54, - fontSize: 18, - fontFamily: AppTheme.fontFamily, - fontWeight: FontWeight.w500, - ), - ), - SizedBox(height: height * 0.05), - - // Phone number field - SizedBox( - height: 60, - width: width * 0.9, - child: _PhoneField( - controller: _phone, - onChanged: () { - setState(() {}); - if (_valid) { - FocusScope.of(context).unfocus(); - } - }, - ), - ), - - if (!_valid && _phone.text.isNotEmpty) - Padding( - padding: EdgeInsets.only( - left: width * 0.02, - top: height * 0.005, - ), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - 'Enter a valid 10-digit mobile number', - style: TextStyle( - color: Colors.red.shade700, - fontSize: 12, - ), - ), - ), - ), - - SizedBox(height: height * 0.04), - RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: const TextStyle( - fontSize: 14, - fontFamily: AppTheme.fontFamily, - fontWeight: FontWeight.w500, - color: Colors.black87, - ), - children: [ - const TextSpan(text: 'By continuing, you agree to '), - TextSpan( - text: 'T&C', - style: const TextStyle( - color: AppColors.primary, - decoration: TextDecoration.none, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - // Launch T&C - }, - ), - const TextSpan(text: ' and '), - TextSpan( - text: 'Privacy Policy', - style: const TextStyle( - color: AppColors.primary, - decoration: TextDecoration.none, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - // Launch Privacy Policy - }, - ), - ], - ), - ), - SizedBox(height: height * 0.04), - RichText( - text: TextSpan( - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant), - children: [ - const TextSpan(text: 'New to Doormile? '), - TextSpan( - text: 'Create account', - style: AppTheme.labelMd.copyWith(color: AppColors.primary), - recognizer: TapGestureRecognizer() - ..onTap = () { - Navigator.pushNamed(context, Routes.signup); - }, - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - bottomNavigationBar: SafeArea( - child: Padding( - padding: EdgeInsets.all(width * 0.05), - child: SizedBox( - height: 56, - width: double.infinity, - child: ElevatedButton( - onPressed: _valid && !_loading ? _login : null, - style: ElevatedButton.styleFrom( - backgroundColor: _valid ? AppColors.primary : Colors.grey.shade300, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - elevation: 0, - ), - child: _loading - ? const SizedBox( - height: 24, - width: 24, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 3, - ), - ) - : const Text( - 'Next', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ), - ); - } -} - -class _PhoneField extends StatelessWidget { - final TextEditingController controller; - final VoidCallback onChanged; - const _PhoneField({required this.controller, required this.onChanged}); - - @override - Widget build(BuildContext context) { - return Container( - height: 65, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - border: Border.all(color: Colors.grey.shade200, width: 1.5), - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - // Simulated Flag/Prefix - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 28, - height: 20, - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: Colors.grey.shade300), - ), - child: const Center( - child: Text('🇮🇳', style: TextStyle(fontSize: 14)), - ), - ), - const SizedBox(width: 10), - Text( - '+91', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - fontFamily: AppTheme.fontFamily, - color: Colors.black87, - ), - ), - ], - ), - Container( - width: 1.5, - height: 32, - margin: const EdgeInsets.symmetric(horizontal: 16), - color: Colors.grey.shade200, - ), - Expanded( - child: TextField( - controller: controller, - onChanged: (_) => onChanged(), - keyboardType: TextInputType.phone, - maxLength: 11, - inputFormatters: [_PhoneFormatter()], - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - color: Colors.black, - letterSpacing: 1, - ), - cursorColor: AppColors.primary, - decoration: InputDecoration( - counterText: '', - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - filled: false, - hintText: 'Enter mobile number', - hintStyle: TextStyle( - color: Colors.grey.shade400, - fontSize: 16, - fontWeight: FontWeight.normal, - letterSpacing: 0, - ), - ), - ), - ), - ], - ), - ); - } -} - -/// Formats input as "XXXXX XXXXX". -class _PhoneFormatter extends TextInputFormatter { - @override - TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { - final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); - final trimmed = digits.length > 10 ? digits.substring(0, 10) : digits; - String out = trimmed; - if (trimmed.length > 5) { - out = '${trimmed.substring(0, 5)} ${trimmed.substring(5)}'; - } - return TextEditingValue( - text: out, - selection: TextSelection.collapsed(offset: out.length), - ); - } -} diff --git a/lib/features/auth/screens/onboarding_screen.dart b/lib/features/auth/screens/onboarding_screen.dart deleted file mode 100644 index 4efcc45..0000000 --- a/lib/features/auth/screens/onboarding_screen.dart +++ /dev/null @@ -1,261 +0,0 @@ -import 'dart:ui'; -import 'package:flutter/material.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; -import 'package:doormile/core/widgets/logo.dart'; - -class _Slide { - final IconData icon; - final Color tint; - final String title; - final String body; - final bool ev; - const _Slide(this.icon, this.tint, this.title, this.body, {this.ev = false}); -} - -class OnboardingScreen extends StatefulWidget { - const OnboardingScreen({super.key}); - - @override - State createState() => _OnboardingScreenState(); -} - -class _OnboardingScreenState extends State { - final _controller = PageController(); - int _index = 0; - - static const _slides = [ - _Slide(Icons.touch_app_rounded, AppColors.primary, 'Send a parcel in one touch', - 'No forms, no waiting. Tap, confirm, done.'), - _Slide(Icons.map_rounded, AppColors.secondary, 'Watch every mile, live', - 'Origin to hub to your doorstep — real-time, always.'), - _Slide(Icons.electric_moped_rounded, AppColors.tertiary, 'EV-first, on time, every time', - '99% on-time, powered by MileTruth AI.', - ev: true), - ]; - - void _next() { - if (_index < _slides.length - 1) { - _controller.nextPage( - duration: const Duration(milliseconds: 350), curve: Curves.easeOut); - } else { - Navigator.pushReplacementNamed(context, Routes.login); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final isLast = _index == _slides.length - 1; - return Scaffold( - backgroundColor: AppColors.surface, - body: AppBackground( - child: SafeArea( - child: Column( - children: [ - // Header - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const DoormileMark(size: 36), - AnimatedOpacity( - opacity: isLast ? 0 : 1, - duration: const Duration(milliseconds: 200), - child: TextButton( - onPressed: isLast - ? null - : () => Navigator.pushReplacementNamed(context, Routes.login), - child: Text('Skip', - style: AppTheme.labelMd.copyWith(color: AppColors.onSurfaceVariant)), - ), - ), - ], - ), - ), - Expanded( - child: PageView.builder( - controller: _controller, - onPageChanged: (i) => setState(() => _index = i), - itemCount: _slides.length, - itemBuilder: (context, i) => _SlideView(slide: _slides[i]), - ), - ), - // Dots - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(_slides.length, (i) { - final active = i == _index; - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - margin: const EdgeInsets.symmetric(horizontal: 4), - height: 8, - width: active ? 24 : 8, - decoration: BoxDecoration( - color: active ? AppColors.primary : AppColors.outlineVariant, - borderRadius: BorderRadius.circular(999), - ), - ); - }), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 28, 16, 24), - child: PrimaryButton( - label: isLast ? 'Get Started' : 'Next', - onPressed: _next, - ), - ), - ], - ), - ), - ), - ); - } -} - -class _SlideView extends StatelessWidget { - final _Slide slide; - const _SlideView({required this.slide}); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 340, maxHeight: 340), - child: AspectRatio( - aspectRatio: 1, - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - slide.tint.withValues(alpha: 0.15), - slide.tint.withValues(alpha: 0.02), - ], - ), - borderRadius: BorderRadius.circular(36), - boxShadow: [ - BoxShadow( - color: slide.tint.withValues(alpha: 0.1), - blurRadius: 40, - offset: const Offset(0, 20), - ), - ], - border: Border.all(color: Colors.white.withValues(alpha: 0.8), width: 2), - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - // Decorative abstract shapes - Positioned( - top: 20, - left: -10, - child: Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: slide.tint.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - ), - ), - Positioned( - right: -30, - bottom: -20, - child: Icon( - Icons.blur_on, - size: 160, - color: slide.tint.withValues(alpha: 0.1), - ), - ), - // Main Icon Glassmorphism Container - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(40), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Container( - width: 160, - height: 160, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(40), - border: Border.all(color: Colors.white.withValues(alpha: 0.6)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 20, - ), - ], - ), - child: Icon(slide.icon, size: 80, color: slide.tint), - ), - ), - ), - ), - // EV badge - if (slide.ev) - Positioned( - top: 24, - right: 24, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.eco, color: AppColors.tertiary, size: 16), - const SizedBox(width: 6), - Text( - 'EV-Powered', - style: TextStyle( - color: AppColors.tertiary, - fontSize: 12, - fontWeight: FontWeight.bold, - fontFamily: AppTheme.fontFamily, - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), - const SizedBox(height: 40), - Text(slide.title, textAlign: TextAlign.center, style: AppTheme.headlineLg), - const SizedBox(height: 8), - Text(slide.body, - textAlign: TextAlign.center, - style: AppTheme.bodyLg.copyWith(color: AppColors.onSurfaceVariant)), - ], - ), - ); - } -} diff --git a/lib/features/auth/screens/otp_screen.dart b/lib/features/auth/screens/otp_screen.dart deleted file mode 100644 index abbfa97..0000000 --- a/lib/features/auth/screens/otp_screen.dart +++ /dev/null @@ -1,288 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart' show kDebugMode; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:sms_autofill/sms_autofill.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/utils/snackbar_utils.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; - -class OtpScreen extends StatefulWidget { - const OtpScreen({super.key}); - - @override - State createState() => _OtpScreenState(); -} - -class _OtpScreenState extends State with CodeAutoFill { - final List _controllers = - List.generate(6, (_) => TextEditingController()); - final List _nodes = List.generate(6, (_) => FocusNode()); - Timer? _timer; - int _seconds = 28; - bool _loading = false; - - @override - void initState() { - super.initState(); - _startTimer(); - listenForCode(); - _prefillDebugBypass(); - WidgetsBinding.instance.addPostFrameCallback((_) => _nodes[0].requestFocus()); - } - - /// Debug-only tester shortcut, see [AppState.verifyOtp]. Compiled out of - /// release builds via kDebugMode. - Future _prefillDebugBypass() async { - if (!kDebugMode) return; - final prefs = await SharedPreferences.getInstance(); - final provider = prefs.getInt('smsDefaultProvider') ?? 0; - final passKey = prefs.getInt('smsPassKey'); - if (provider != 1 || passKey == null) return; - if (!mounted || _controllers.any((c) => c.text.isNotEmpty)) return; - - final passKeyStr = passKey.toString().padLeft(6, '0'); - for (int i = 0; i < 6; i++) { - _controllers[i].text = passKeyStr[i]; - } - setState(() {}); - } - - @override - void codeUpdated() { - if (!mounted) return; - final digits = (code ?? '').replaceAll(RegExp(r'[^0-9]'), ''); - if (digits.length < 6) return; - for (int i = 0; i < 6; i++) { - _controllers[i].text = digits[i]; - } - setState(() {}); - _verify(); - } - - void _startTimer() { - _seconds = 28; - _timer?.cancel(); - _timer = Timer.periodic(const Duration(seconds: 1), (t) { - if (_seconds == 0) { - t.cancel(); - } else { - setState(() => _seconds--); - } - }); - } - - String get _code => _controllers.map((c) => c.text).join(); - - void _verify() async { - if (_loading) return; - FocusScope.of(context).unfocus(); - setState(() => _loading = true); - - await context.read().verifyOtp( - _code, - onSuccess: () { - if (!mounted) return; - final state = context.read(); - if (state.isResettingPin) { - Navigator.pushReplacementNamed(context, Routes.resetPin); - } else { - Navigator.pushReplacementNamed(context, Routes.setPin); - } - }, - onError: (err) { - if (!mounted) return; - setState(() => _loading = false); - SnackbarUtils.showError(context, err); - for (var c in _controllers) { - c.clear(); - } - _nodes[0].requestFocus(); - }, - ); - } - - void _resendOtp() async { - setState(() => _loading = true); - await context.read().sendOtp( - context.read().phone, - onSuccess: () { - if (!mounted) return; - setState(() => _loading = false); - _startTimer(); - SnackbarUtils.showSuccess(context, 'OTP Resent'); - }, - onError: (err) { - if (!mounted) return; - setState(() => _loading = false); - SnackbarUtils.showError(context, err); - }, - ); - } - - void _onChanged(int i, String v) { - if (v.length > 1) { - final chars = v.replaceAll(RegExp(r'[^0-9]'), '').split(''); - for (int j = 0; j < 6; j++) { - _controllers[j].text = j < chars.length ? chars[j] : ''; - } - _nodes[5].requestFocus(); - setState(() {}); - if (_code.length == 6) _verify(); - return; - } - if (v.isNotEmpty && i < 5) _nodes[i + 1].requestFocus(); - if (v.isEmpty && i > 0) _nodes[i - 1].requestFocus(); - setState(() {}); - if (_code.length == 6) _verify(); - } - - @override - void dispose() { - _timer?.cancel(); - cancel(); - for (final c in _controllers) { - c.dispose(); - } - for (final n in _nodes) { - n.dispose(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final phone = context.watch().phone; - final filled = _code.length == 6; - return Scaffold( - backgroundColor: AppColors.surface, - appBar: AppBar( - leading: BackButton(color: AppColors.primary, onPressed: () => Navigator.pop(context)), - ), - body: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: AppCard( - padding: const EdgeInsets.all(28), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 88, - height: 88, - decoration: const BoxDecoration( - color: AppColors.primaryFixed, shape: BoxShape.circle), - child: const Icon(Icons.sms_rounded, size: 40, color: AppColors.primary), - ), - const SizedBox(height: 24), - Text('Verify your number', - textAlign: TextAlign.center, style: AppTheme.headlineLg), - const SizedBox(height: 8), - Text.rich( - TextSpan( - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant), - children: [ - const TextSpan(text: 'We sent a 6-digit code to '), - TextSpan( - text: '+91 $phone', - style: AppTheme.labelMd, - ), - ], - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 28), - Row( - children: List.generate( - 6, - (i) => Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 3), - child: _OtpBox( - controller: _controllers[i], - node: _nodes[i], - onChanged: (v) => _onChanged(i, v), - ), - ), - ), - ), - ), - const SizedBox(height: 28), - if (_seconds > 0) - Text('Resend code in 0:${_seconds.toString().padLeft(2, '0')}', - style: AppTheme.labelMd.copyWith(color: AppColors.onSurfaceVariant)) - else - TextButton( - onPressed: _resendOtp, - child: Text('Resend code now', - style: AppTheme.labelMd - .copyWith(color: AppColors.primary, fontWeight: FontWeight.w700)), - ), - const SizedBox(height: 24), - PrimaryButton( - label: 'Verify & Continue', - trailingIcon: Icons.arrow_forward, - loading: _loading, - onPressed: filled ? _verify : null, - ), - ], - ), - ), - ), - ), - ), - ), - ); - } -} - -class _OtpBox extends StatelessWidget { - final TextEditingController controller; - final FocusNode node; - final ValueChanged onChanged; - const _OtpBox({required this.controller, required this.node, required this.onChanged}); - - @override - Widget build(BuildContext context) { - final filled = controller.text.isNotEmpty; - return SizedBox( - height: 56, - child: TextField( - controller: controller, - focusNode: node, - onChanged: onChanged, - textAlign: TextAlign.center, - keyboardType: TextInputType.number, - maxLength: 6, - autofillHints: const [AutofillHints.oneTimeCode], - cursorColor: AppColors.primary, - style: AppTheme.headlineMd, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - decoration: InputDecoration( - counterText: '', - filled: true, - contentPadding: EdgeInsets.zero, - fillColor: filled ? AppColors.primaryFixed.withValues(alpha: 0.3) : AppColors.surface, - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.primary, width: 2), - ), - ), - ), - ); - } -} diff --git a/lib/features/auth/screens/pin_screen.dart b/lib/features/auth/screens/pin_screen.dart deleted file mode 100644 index 30589f6..0000000 --- a/lib/features/auth/screens/pin_screen.dart +++ /dev/null @@ -1,317 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/utils/snackbar_utils.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; - -enum PinMode { enter, set, reset } - -class PinScreen extends StatefulWidget { - final PinMode mode; - const PinScreen({super.key, required this.mode}); - - @override - State createState() => _PinScreenState(); -} - -class _PinScreenState extends State { - final _controllers = List.generate(4, (_) => TextEditingController()); - final _nodes = List.generate(4, (_) => FocusNode()); - String _code = ''; - bool _loading = false; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _nodes[0].requestFocus(); - }); - } - - void _onBackspaceOnEmpty(int i) { - if (i > 0) { - _controllers[i - 1].clear(); - _nodes[i - 1].requestFocus(); - _updateCode(); - } - } - - void _onChanged(int i, String v) { - if (v.length > 1) { - final chars = v.replaceAll(RegExp(r'[^0-9]'), '').split(''); - for (int j = 0; j < 4; j++) { - _controllers[j].text = j < chars.length ? chars[j] : ''; - } - _nodes[3].requestFocus(); - _updateCode(); - if (_code.length == 4) _submit(); - return; - } - if (v.isNotEmpty && i < 3) _nodes[i + 1].requestFocus(); - _updateCode(); - if (_code.length == 4) _submit(); - } - - void _updateCode() { - setState(() { - _code = _controllers.map((c) => c.text).join(); - }); - } - - void _submit() async { - FocusScope.of(context).unfocus(); - setState(() => _loading = true); - final state = context.read(); - - try { - if (widget.mode == PinMode.set) { - await state.registerWithPin(_code); - } else if (widget.mode == PinMode.reset) { - await state.resetPinWithBackend(_code); - } else { - await state.verifyPinWithBackend(_code); - } - - if (!mounted) return; - Navigator.pushNamedAndRemoveUntil(context, Routes.home, (r) => false); - } catch (e) { - if (!mounted) return; - setState(() => _loading = false); - SnackbarUtils.showError(context, e.toString()); - for (var c in _controllers) c.clear(); - _nodes[0].requestFocus(); - _updateCode(); - } - } - - void _forgotPin() async { - setState(() => _loading = true); - await context.read().sendOtp( - context.read().phone, - resetPin: true, - onSuccess: () { - if (!mounted) return; - setState(() => _loading = false); - Navigator.pushNamed(context, Routes.otp); - }, - onError: (err) { - if (!mounted) return; - setState(() => _loading = false); - SnackbarUtils.showError(context, err); - }, - ); - } - - @override - Widget build(BuildContext context) { - String title = 'Enter PIN'; - String subtitle = 'Enter your 4-digit security PIN to log in.'; - if (widget.mode == PinMode.set) { - title = 'Set PIN'; - subtitle = 'Create a 4-digit security PIN for your account.'; - } else if (widget.mode == PinMode.reset) { - title = 'Set New PIN'; - subtitle = 'Create a new 4-digit security PIN.'; - } - - return Scaffold( - body: Container( - width: double.infinity, - height: double.infinity, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topRight, - end: Alignment.bottomLeft, - colors: [ - AppColors.tertiary.withValues(alpha: 0.05), - Colors.white, - AppColors.primary.withValues(alpha: 0.05), - ], - ), - ), - child: Stack( - children: [ - SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox(height: MediaQuery.of(context).size.height * 0.12), - Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.1), - shape: BoxShape.circle, - ), - child: const Icon(Icons.lock_outline, size: 48, color: AppColors.primary), - ), - const SizedBox(height: 32), - Text( - title, - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - color: AppColors.primary, - fontFamily: AppTheme.fontFamily, - ), - ), - const SizedBox(height: 12), - Text( - subtitle, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 18, - color: Colors.black54, - fontFamily: AppTheme.fontFamily, - ), - ), - const SizedBox(height: 48), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: List.generate( - 4, - (i) => _PinBox( - controller: _controllers[i], - node: _nodes[i], - onChanged: (v) => _onChanged(i, v), - onBackspaceOnEmpty: () => _onBackspaceOnEmpty(i), - ), - ), - ), - const SizedBox(height: 24), - if (widget.mode == PinMode.enter) - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SizedBox(), // Empty space for left side - InkWell( - onTap: _forgotPin, - child: const Text( - "Forget Pin?", - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: AppColors.primary, - fontFamily: AppTheme.fontFamily, - decoration: TextDecoration.underline, - ), - ), - ), - ], - ), - if (_loading) ...[ - const SizedBox(height: 32), - const CircularProgressIndicator(color: AppColors.primary), - ], - ], - ), - ), - ), - - // Back Button - Positioned( - top: MediaQuery.of(context).padding.top + 16, - left: 16, - child: InkWell( - onTap: () => Navigator.pop(context), - borderRadius: BorderRadius.circular(30), - child: Container( - padding: const EdgeInsets.all(10), - decoration: const BoxDecoration( - color: Colors.black12, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.arrow_back, - color: Colors.black, - size: 24, - ), - ), - ), - ), - ], - ), - ), - ); - } -} - -class _PinBox extends StatelessWidget { - final TextEditingController controller; - final FocusNode node; - final ValueChanged onChanged; - final VoidCallback onBackspaceOnEmpty; - - const _PinBox({ - required this.controller, - required this.node, - required this.onChanged, - required this.onBackspaceOnEmpty, - }); - - @override - Widget build(BuildContext context) { - final filled = controller.text.isNotEmpty; - - return Container( - width: 58, - height: 64, - decoration: BoxDecoration( - color: filled ? AppColors.primary : Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: filled ? AppColors.primary.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.04), - blurRadius: filled ? 12 : 20, - offset: const Offset(0, 8), - ), - ], - border: Border.all( - color: filled ? AppColors.primary : Colors.grey.shade200, - width: 1.5, - ), - ), - child: RawKeyboardListener( - focusNode: FocusNode(), - onKey: (event) { - if (event is RawKeyDownEvent && - event.logicalKey == LogicalKeyboardKey.backspace && - controller.text.isEmpty) { - onBackspaceOnEmpty(); - } - }, - child: TextField( - controller: controller, - focusNode: node, - onChanged: onChanged, - textAlign: TextAlign.center, - textAlignVertical: TextAlignVertical.center, - obscureText: true, - obscuringCharacter: '●', - keyboardType: TextInputType.number, - maxLength: 1, - style: TextStyle( - fontSize: 28, - fontWeight: FontWeight.bold, - color: filled ? Colors.white : AppColors.primary, - ), - decoration: const InputDecoration( - counterText: "", - contentPadding: EdgeInsets.zero, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - filled: false, - ), - ), - ), - ); - } -} diff --git a/lib/features/auth/screens/signup_screen.dart b/lib/features/auth/screens/signup_screen.dart deleted file mode 100644 index eae38eb..0000000 --- a/lib/features/auth/screens/signup_screen.dart +++ /dev/null @@ -1,275 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/utils/snackbar_utils.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; -import 'package:doormile/core/widgets/logo.dart'; - -class SignupScreen extends StatefulWidget { - const SignupScreen({super.key}); - - @override - State createState() => _SignupScreenState(); -} - -class _SignupScreenState extends State { - final _name = TextEditingController(); - final _email = TextEditingController(); - final _phone = TextEditingController(); - bool _loading = false; - - bool get _valid => - _name.text.trim().isNotEmpty && - _email.text.trim().isNotEmpty && - _phone.text.replaceAll(' ', '').length == 10; - - void _sendOtp() async { - if (!_valid) return; - FocusScope.of(context).unfocus(); - setState(() => _loading = true); - - await context.read().sendOtp( - _phone.text, - firstName: _name.text.trim(), - email: _email.text.trim(), - onSuccess: () { - if (!mounted) return; - setState(() => _loading = false); - Navigator.pushNamed(context, Routes.otp); - }, - onError: (err) { - if (!mounted) return; - setState(() => _loading = false); - SnackbarUtils.showError(context, err); - }, - ); - } - - @override - void dispose() { - _name.dispose(); - _email.dispose(); - _phone.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.surface, - appBar: AppBar( - leading: BackButton(color: AppColors.primary, onPressed: () => Navigator.pop(context)), - ), - body: SafeArea( - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const DoormileMark(size: 64), - const SizedBox(height: 32), - Text('Create Account', style: AppTheme.headlineLg), - const SizedBox(height: 6), - Text('Join Doormile for eco-friendly deliveries', - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)), - const SizedBox(height: 40), - - // Name Field - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(left: 4, bottom: 6), - child: Text('Full Name', - style: AppTheme.labelMd - .copyWith(color: AppColors.onSurfaceVariant)), - ), - ), - _CustomTextField( - controller: _name, - hint: 'e.g. Rahul Sharma', - icon: Icons.person_outline, - onChanged: () => setState(() {}), - ), - const SizedBox(height: 20), - - // Email Field - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(left: 4, bottom: 6), - child: Text('Email Address', - style: AppTheme.labelMd - .copyWith(color: AppColors.onSurfaceVariant)), - ), - ), - _CustomTextField( - controller: _email, - hint: 'e.g. rahul@example.com', - icon: Icons.email_outlined, - keyboardType: TextInputType.emailAddress, - onChanged: () => setState(() {}), - ), - const SizedBox(height: 20), - - // Phone Field - Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.only(left: 4, bottom: 6), - child: Text('Mobile Number', - style: AppTheme.labelMd - .copyWith(color: AppColors.onSurfaceVariant)), - ), - ), - _PhoneField(controller: _phone, onChanged: () => setState(() {})), - const SizedBox(height: 36), - - PrimaryButton( - label: 'Send OTP', - loading: _loading, - onPressed: _valid ? _sendOtp : null, - ), - const SizedBox(height: 20), - ], - ), - ), - ), - ], - ), - ), - ); - } -} - -class _CustomTextField extends StatelessWidget { - final TextEditingController controller; - final String hint; - final IconData icon; - final TextInputType? keyboardType; - final VoidCallback onChanged; - - const _CustomTextField({ - required this.controller, - required this.hint, - required this.icon, - this.keyboardType, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.outlineVariant), - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Icon(icon, size: 20, color: AppColors.onSurfaceVariant), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: controller, - onChanged: (_) => onChanged(), - keyboardType: keyboardType, - style: AppTheme.bodyLg, - cursorColor: AppColors.primary, - decoration: InputDecoration( - filled: false, - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - hintText: hint, - hintStyle: AppTheme.bodyLg.copyWith(color: AppColors.outlineVariant), - ), - ), - ), - ], - ), - ); - } -} - -class _PhoneField extends StatelessWidget { - final TextEditingController controller; - final VoidCallback onChanged; - const _PhoneField({required this.controller, required this.onChanged}); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.outlineVariant), - ), - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: [ - Text('+91', - style: AppTheme.bodyMd - .copyWith(color: AppColors.primary, fontWeight: FontWeight.w600)), - const Icon(Icons.expand_more, size: 20, color: AppColors.onSurfaceVariant), - Container( - width: 1, - height: 28, - margin: const EdgeInsets.symmetric(horizontal: 12), - color: AppColors.outlineVariant, - ), - Expanded( - child: TextField( - controller: controller, - onChanged: (_) => onChanged(), - keyboardType: TextInputType.phone, - maxLength: 11, - inputFormatters: [_PhoneFormatter()], - style: AppTheme.bodyLg.copyWith(letterSpacing: 1), - cursorColor: AppColors.primary, - decoration: InputDecoration( - filled: false, - counterText: '', - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - hintText: '00000 00000', - hintStyle: - AppTheme.bodyLg.copyWith(color: AppColors.outlineVariant, letterSpacing: 1), - ), - ), - ), - ], - ), - ); - } -} - -/// Formats input as "XXXXX XXXXX". -class _PhoneFormatter extends TextInputFormatter { - @override - TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { - final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); - final trimmed = digits.length > 10 ? digits.substring(0, 10) : digits; - String out = trimmed; - if (trimmed.length > 5) { - out = '${trimmed.substring(0, 5)} ${trimmed.substring(5)}'; - } - return TextEditingValue( - text: out, - selection: TextSelection.collapsed(offset: out.length), - ); - } -} diff --git a/lib/features/auth/screens/splash_screen.dart b/lib/features/auth/screens/splash_screen.dart deleted file mode 100644 index be2271f..0000000 --- a/lib/features/auth/screens/splash_screen.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/logo.dart'; - -class SplashScreen extends StatefulWidget { - const SplashScreen({super.key}); - - @override - State createState() => _SplashScreenState(); -} - -class _SplashScreenState extends State with TickerProviderStateMixin { - late final AnimationController _entrance = - AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))..forward(); - late final AnimationController _bar = - AnimationController(vsync: this, duration: const Duration(seconds: 3))..repeat(); - - @override - void initState() { - super.initState(); - _checkSession(); - } - - void _checkSession() async { - final prefs = await SharedPreferences.getInstance(); - final token = prefs.getString('auth_token'); - - await Future.delayed(const Duration(milliseconds: 2600)); - - if (mounted) { - if (token != null && token.isNotEmpty) { - Navigator.pushReplacementNamed(context, Routes.home); - } else { - Navigator.pushReplacementNamed(context, Routes.onboarding); - } - } - } - - @override - void dispose() { - _entrance.dispose(); - _bar.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final fade = CurvedAnimation(parent: _entrance, curve: Curves.easeOutCubic); - return Scaffold( - backgroundColor: Colors.white, - body: Stack( - fit: StackFit.expand, - children: [ - // Background Image - FadeTransition( - opacity: fade, - child: Image.asset( - 'assets/images/Introsplashscreen.png', - fit: BoxFit.cover, - ), - ), - - // Loading Indicator - Positioned( - bottom: 60, - left: 0, - right: 0, - child: Center( - child: FadeTransition( - opacity: fade, - child: const SizedBox( - width: 32, - height: 32, - child: CircularProgressIndicator( - strokeWidth: 3, - color: AppColors.primary, - ), - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/booking/screens/booking_pickup_screen.dart b/lib/features/booking/screens/booking_pickup_screen.dart deleted file mode 100644 index 4499af0..0000000 --- a/lib/features/booking/screens/booking_pickup_screen.dart +++ /dev/null @@ -1,871 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:geolocator/geolocator.dart'; -import 'package:geocoding/geocoding.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/utils/snackbar_utils.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; -import 'package:doormile/features/booking/screens/booking_scaffold.dart'; - -class BookingPickupScreen extends StatefulWidget { - const BookingPickupScreen({super.key}); - - @override - State createState() => _BookingPickupScreenState(); -} - -class _BookingPickupScreenState extends State { - late final TextEditingController _pickup; - late final TextEditingController _destPincode; - late final TextEditingController _name; - late final TextEditingController _mobile; - late final TextEditingController _notes; - late final _formKey = GlobalKey(); - bool _loading = false; - - String _category = ''; - String _serviceType = ''; - - @override - void initState() { - super.initState(); - final draft = context.read().draft; - _pickup = TextEditingController(text: draft.pickupAddress); - _destPincode = TextEditingController(text: draft.dropPincode); - _name = TextEditingController(text: draft.contactName); - _mobile = TextEditingController(text: draft.contactMobile); - _notes = TextEditingController(text: draft.notes); - _category = draft.parcelType; - _serviceType = draft.service; - } - - @override - void dispose() { - _pickup.dispose(); - _destPincode.dispose(); - _name.dispose(); - _mobile.dispose(); - _notes.dispose(); - super.dispose(); - } - - void _showValidationError(String title, String message) { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (ctx) => Container( - padding: const EdgeInsets.all(24), - decoration: const BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.error_outline_rounded, size: 56, color: AppColors.error), - const SizedBox(height: 16), - Text(title, style: AppTheme.headlineSm), - const SizedBox(height: 8), - Text( - message, - textAlign: TextAlign.center, - style: AppTheme.bodyLg.copyWith(color: AppColors.onSurfaceVariant), - ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: () => Navigator.pop(ctx), - style: FilledButton.styleFrom( - backgroundColor: AppColors.primary, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - child: const Text('Got it'), - ), - ), - ], - ), - ), - ), - ); - } - - Future _submit() async { - if (_pickup.text.trim().isEmpty) { - _showValidationError('Missing Location', 'Please enter or select a pickup address where the Miler should arrive.'); - return; - } - if (_destPincode.text.trim().length < 6) { - _showValidationError('Missing Pincode', 'Please enter the 6-digit receiver pincode so we can estimate the fare.'); - return; - } - if (_category.isEmpty) { - _showValidationError('Missing Category', 'Please select a parcel category so we can assign the right Miler.'); - return; - } - if (_serviceType.isEmpty) { - _showValidationError('Missing Service Type', 'Please select whether you need Normal or Express delivery.'); - return; - } - - final appState = context.read(); - final draft = appState.draft; - draft.pickupAddress = _pickup.text; - draft.dropPincode = _destPincode.text.trim(); - draft.contactName = _name.text; - draft.contactMobile = _mobile.text; - draft.notes = _notes.text; - draft.parcelType = _category; - draft.service = _serviceType; - - // Extract pincode from typed address if GPS/saved address didn't set it. - if (draft.pickupPincode.isEmpty) { - final match = RegExp(r'\b\d{6}\b').firstMatch(_pickup.text); - if (match != null) draft.pickupPincode = match.group(0)!; - } - - if (draft.pickupPincode.isEmpty) { - _showValidationError('Missing Pickup Pincode', - 'We couldn\'t detect a pincode for your pickup address. Please use "Use GPS current location", pick a saved address, or include the 6-digit pincode in the address.'); - return; - } - - setState(() => _loading = true); - await appState.fetchPriceEstimate(); - setState(() => _loading = false); - - if (mounted) Navigator.pushNamed(context, Routes.bookingPreview); - } - - @override - Widget build(BuildContext context) { - final state = context.watch(); - return BookingScaffold( - title: 'Book a Pickup', - step: 1, - stepLabel: 'Pickup Request', - buttonLabel: 'Review Booking', - enabled: !_loading, - loading: _loading, - onContinue: _submit, - children: [ - // Pickup location card - AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: AppColors.success.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - ), - child: const Icon(Icons.location_on, size: 14, color: AppColors.success), - ), - const SizedBox(width: 10), - Text('PICKUP LOCATION', - style: AppTheme.caption.copyWith( - letterSpacing: 1, fontWeight: FontWeight.w600)), - ], - ), - const SizedBox(height: 8), - TextField( - controller: _pickup, - style: AppTheme.headlineSm, - cursorColor: AppColors.primary, - maxLines: 2, - minLines: 1, - decoration: const InputDecoration( - isDense: true, - border: InputBorder.none, - hintText: 'Enter pickup address manually', - ), - ), - const Divider(height: 20, color: AppColors.surfaceContainer), - InkWell( - onTap: () async { - setState(() => _loading = true); - - try { - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied) { - throw Exception('Location permissions are denied'); - } - } - - if (permission == LocationPermission.deniedForever) { - if (!mounted) return; - setState(() => _loading = false); - bool? open = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Permission Denied'), - content: const Text('Location permissions are permanently denied. Please enable them in app settings.'), - actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), - TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Settings')), - ], - ), - ); - if (open == true) await Geolocator.openAppSettings(); - return; - } - - bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); - if (!serviceEnabled) { - if (!mounted) return; - setState(() => _loading = false); - _showLocationDisabledSheet(); - return; - } - - Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high); - List placemarks = await placemarkFromCoordinates(position.latitude, position.longitude); - - if (!mounted) return; - setState(() => _loading = false); - - final state = context.read(); - if (placemarks.isNotEmpty) { - final place = placemarks.first; - final area = '${place.subLocality ?? place.locality}, ${place.administrativeArea}'; - final pincode = place.postalCode ?? ''; - final fullAddress = '${place.street}, $area, $pincode'; - _showLocationConfirmationSheet(context, state, area, pincode, fullAddress, position.latitude, position.longitude); - } else { - throw Exception('Could not determine address from coordinates.'); - } - } catch (e) { - if (!mounted) return; - setState(() => _loading = false); - SnackbarUtils.showError(context, e.toString()); - } - }, - child: Row( - children: [ - if (_loading) - const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary)) - else - const Icon(Icons.my_location, size: 18, color: AppColors.primary), - const SizedBox(width: 8), - Flexible( - child: Text(_loading ? 'Locating...' : 'Use GPS current location', - style: AppTheme.labelMd.copyWith(color: AppColors.primary), - overflow: TextOverflow.ellipsis), - ), - ], - ), - ), - const Divider(height: 20, color: AppColors.surfaceContainer), - Text('SAVED ADDRESSES', style: AppTheme.caption.copyWith(letterSpacing: 1, fontWeight: FontWeight.w600)), - const SizedBox(height: 12), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - clipBehavior: Clip.none, - child: Row( - children: [ - _SavedAddressChip( - icon: Icons.home_rounded, - label: 'Home', - address: '12th Main, Indiranagar, Bengaluru, Karnataka 560038', - pincode: '560038', - onSelect: (addr, pin) => setState(() { - _pickup.text = addr; - final state = context.read(); - state.draft.pickupPincode = pin; - _name.text = state.firstName; - _mobile.text = state.phone; - }), - ), - const SizedBox(width: 12), - _SavedAddressChip( - icon: Icons.business_rounded, - label: 'Office', - address: 'Sector 4, HSR Layout, Bengaluru, Karnataka 560102', - pincode: '560102', - onSelect: (addr, pin) => setState(() { - _pickup.text = addr; - final state = context.read(); - state.draft.pickupPincode = pin; - _name.text = state.firstName; - _mobile.text = state.phone; - }), - ), - const SizedBox(width: 12), - _SavedAddressChip( - icon: Icons.favorite_rounded, - label: "Mom's House", - address: 'Jayanagar 4th Block, Bengaluru, Karnataka 560011', - pincode: '560011', - onSelect: (addr, pin) => setState(() { - _pickup.text = addr; - final state = context.read(); - state.draft.pickupPincode = pin; - _name.text = state.firstName; - _mobile.text = state.phone; - }), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - // Destination Pincode card - AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(999), - ), - child: const Icon(Icons.pin_drop, size: 14, color: AppColors.primary), - ), - const SizedBox(width: 10), - Text('RECEIVER PINCODE', - style: AppTheme.caption.copyWith( - letterSpacing: 1, fontWeight: FontWeight.w600)), - ], - ), - const SizedBox(height: 8), - TextField( - controller: _destPincode, - keyboardType: TextInputType.number, - maxLength: 6, - style: AppTheme.headlineSm, - cursorColor: AppColors.primary, - decoration: const InputDecoration( - isDense: true, - border: InputBorder.none, - counterText: "", - hintText: 'Enter 6-digit delivery pincode', - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - // Category Card - AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Category', - style: AppTheme.caption.copyWith( - letterSpacing: 1, fontWeight: FontWeight.w600)), - const SizedBox(height: 12), - Column( - children: [ - Row( - children: [ - Expanded(child: _SelectionCard(width: null, icon: Icons.category, label: 'General Goods', selected: _category == 'General Goods', onTap: () => setState(() => _category = 'General Goods'))), - const SizedBox(width: 8), - Expanded(child: _SelectionCard(width: null, icon: Icons.menu_book, label: 'Books & Documents', selected: _category == 'Books & Documents', onTap: () => setState(() => _category = 'Books & Documents'))), - const SizedBox(width: 8), - Expanded(child: _SelectionCard(width: null, icon: Icons.devices, label: 'Electronics & Gadgets', selected: _category == 'Electronics & Gadgets', onTap: () => setState(() => _category = 'Electronics & Gadgets'))), - const SizedBox(width: 8), - Expanded(child: _SelectionCard(width: null, icon: Icons.checkroom, label: 'Clothing & Textiles', selected: _category == 'Clothing & Textiles', onTap: () => setState(() => _category = 'Clothing & Textiles'))), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded(child: _SelectionCard(width: null, icon: Icons.wine_bar, label: 'Fragile Items', selected: _category == 'Fragile Items', onTap: () => setState(() => _category = 'Fragile Items'))), - const SizedBox(width: 8), - Expanded(child: _SelectionCard(width: null, icon: Icons.medical_services, label: 'Medical & Pharma', selected: _category == 'Medical & Pharma', onTap: () => setState(() => _category = 'Medical & Pharma'))), - const SizedBox(width: 8), - Expanded(child: _SelectionCard(width: null, icon: Icons.directions_car, label: 'Automotive Parts', selected: _category == 'Automotive Parts', onTap: () => setState(() => _category = 'Automotive Parts'))), - const SizedBox(width: 8), - Expanded(child: _SelectionCard(width: null, icon: Icons.restaurant, label: 'Food & Perishables', selected: _category == 'Food & Perishables', onTap: () => setState(() => _category = 'Food & Perishables'))), - ], - ), - ], - ), - ], - ), - ), - const SizedBox(height: 16), - - - - - - // Type Card - AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Service Type', - style: AppTheme.caption.copyWith( - letterSpacing: 1, fontWeight: FontWeight.w600)), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _ServiceCard( - title: 'Normal', - subtitle: 'Standard delivery', - icon: Icons.local_shipping_outlined, - selected: _serviceType == 'Normal', - onTap: () => setState(() => _serviceType = 'Normal'), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _ServiceCard( - title: 'Express', - subtitle: 'Lightning fast', - icon: Icons.bolt, - selected: _serviceType == 'Express', - onTap: () => setState(() => _serviceType = 'Express'), - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 16), - - // Contact Details Card - AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Contact Person (Optional)', - style: AppTheme.caption.copyWith( - letterSpacing: 1, fontWeight: FontWeight.w600)), - const SizedBox(height: 12), - TextField( - controller: _name, - style: AppTheme.bodyLg, - decoration: InputDecoration( - hintText: 'Name (e.g. Arun)', - isDense: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.primary), - ), - ), - ), - const SizedBox(height: 12), - TextField( - controller: _mobile, - keyboardType: TextInputType.phone, - style: AppTheme.bodyLg, - decoration: InputDecoration( - hintText: 'Mobile Number', - isDense: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.primary), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 16), - - // Notes Card - AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Pickup Notes (Optional)', - style: AppTheme.caption.copyWith( - letterSpacing: 1, fontWeight: FontWeight.w600)), - const SizedBox(height: 12), - TextField( - controller: _notes, - maxLines: 3, - style: AppTheme.bodyLg, - decoration: InputDecoration( - hintText: 'Call before coming, Shop closes at 8 PM, etc.', - isDense: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide(color: AppColors.primary), - ), - ), - ), - ], - ), - ), - const SizedBox(height: 24), - - Text('RECENT & SAVED', - style: AppTheme.caption - .copyWith(letterSpacing: 1.2, fontWeight: FontWeight.w700)), - const SizedBox(height: 12), - ...state.savedAddresses.map((a) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: AppCard( - onTap: () { - final draft = context.read().draft; - draft.pickupPincode = a.pincode; - draft.pickupLatitude = a.latitude; - draft.pickupLongitude = a.longitude; - setState(() => _pickup.text = '${a.label}, ${a.line}'); - }, - child: Row( - children: [ - IconBadge( - icon: a.icon, bg: a.iconBg, color: a.iconColor, circle: true), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(a.label, style: AppTheme.labelMd), - const SizedBox(height: 2), - Text(a.line, style: AppTheme.caption), - ], - ), - ), - ], - ), - ), - )), - const SizedBox(height: 8), - const EcoBanner( - text: 'Miler will collect parcel details, weight, and pricing upon arrival.'), - ], - ); - } - - void _showLocationConfirmationSheet(BuildContext context, AppState state, String area, String pincode, String fullAddress, double latitude, double longitude) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => Container( - padding: const EdgeInsets.all(24), - decoration: const BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: 120, - width: double.infinity, - decoration: BoxDecoration( - color: AppColors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16), - image: const DecorationImage( - image: AssetImage('assets/images/map_placeholder.png'), // Will failover smoothly if missing - fit: BoxFit.cover, - ), - ), - child: const Center( - child: IconBadge(icon: Icons.location_on, bg: AppColors.primaryContainer, color: AppColors.primary, circle: true), - ), - ), - const SizedBox(height: 24), - Text('GPS Location Found', style: AppTheme.headlineSm), - const SizedBox(height: 8), - Text(area, style: AppTheme.labelMd.copyWith(color: AppColors.primary)), - const SizedBox(height: 4), - Text('Pincode: $pincode', style: AppTheme.caption), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => Navigator.pop(context), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - child: const Text('Cancel'), - ), - ), - const SizedBox(width: 12), - Expanded( - child: PrimaryButton( - label: 'Confirm', - onPressed: () { - state.draft.pickupPincode = pincode; - state.draft.pickupLatitude = latitude; - state.draft.pickupLongitude = longitude; - setState(() { - _pickup.text = fullAddress; - _name.text = state.firstName; - _mobile.text = state.phone; - }); - Navigator.pop(context); - }, - ), - ), - ], - ), - ], - ), - ), - ), - ); - } - - void _showLocationDisabledSheet() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (ctx) => Container( - padding: const EdgeInsets.all(24), - decoration: const BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - child: SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.location_off_rounded, size: 64, color: AppColors.error), - const SizedBox(height: 16), - Text( - "Location is turned off", - style: AppTheme.headlineSm, - ), - const SizedBox(height: 8), - Text( - "Please turn on location to accurately detect your pickup address.", - textAlign: TextAlign.center, - style: AppTheme.bodyLg.copyWith(color: AppColors.onSurfaceVariant), - ), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: () => Navigator.pop(ctx), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - ), - child: const Text('Cancel'), - ), - ), - const SizedBox(width: 12), - Expanded( - child: PrimaryButton( - label: 'Turn On', - onPressed: () async { - Navigator.pop(ctx); - await Geolocator.openLocationSettings(); - }, - ), - ), - ], - ), - ], - ), - ), - ), - ); - } -} - -class _SavedAddressChip extends StatelessWidget { - final IconData icon; - final String label; - final String address; - final String pincode; - final Function(String address, String pincode) onSelect; - - const _SavedAddressChip({ - required this.icon, - required this.label, - required this.address, - required this.pincode, - required this.onSelect, - }); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: () => onSelect(address, pincode), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - border: Border.all(color: AppColors.surfaceContainerHigh), - borderRadius: BorderRadius.circular(12), - color: AppColors.surface, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: AppColors.primary), - const SizedBox(width: 8), - Text(label, style: AppTheme.labelMd), - ], - ), - ), - ); - } -} - -class _SelectionCard extends StatelessWidget { - final IconData icon; - final String label; - final bool selected; - final VoidCallback onTap; - final double? width; - - const _SelectionCard({ - required this.icon, - required this.label, - required this.selected, - required this.onTap, - this.width = 85, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: width, - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), - decoration: BoxDecoration( - color: selected ? AppColors.primaryFixed : AppColors.surface, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: selected ? AppColors.primary : AppColors.surfaceContainerHigh, - width: selected ? 2 : 1, - ), - boxShadow: selected - ? [BoxShadow(color: AppColors.primary.withValues(alpha: 0.15), blurRadius: 12, offset: const Offset(0, 4))] - : [], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, color: selected ? AppColors.primary : AppColors.onSurfaceVariant, size: 24), - const SizedBox(height: 12), - Text( - label, - textAlign: TextAlign.center, - maxLines: 2, - style: AppTheme.labelMd.copyWith( - color: selected ? AppColors.primary : AppColors.onSurfaceVariant, - fontWeight: selected ? FontWeight.w700 : FontWeight.w500, - fontSize: 10, - ), - ), - ], - ), - ), - ); - } -} - -class _ServiceCard extends StatelessWidget { - final String title; - final String subtitle; - final IconData icon; - final bool selected; - final VoidCallback onTap; - - const _ServiceCard({ - required this.title, - required this.subtitle, - required this.icon, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - gradient: selected - ? const LinearGradient( - colors: [AppColors.primaryContainer, AppColors.primary], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ) - : null, - color: selected ? null : AppColors.surface, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: selected ? AppColors.primary : AppColors.surfaceContainerHigh, - width: selected ? 2 : 1, - ), - boxShadow: selected - ? [BoxShadow(color: AppColors.primary.withValues(alpha: 0.3), blurRadius: 16, offset: const Offset(0, 6))] - : [], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: selected ? Colors.white.withValues(alpha: 0.2) : AppColors.surfaceContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, color: selected ? Colors.white : AppColors.onSurfaceVariant, size: 24), - ), - const SizedBox(height: 16), - Text( - title, - style: AppTheme.headlineSm.copyWith( - color: selected ? Colors.white : AppColors.onSurface, - fontSize: 14, - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: AppTheme.caption.copyWith( - color: selected ? Colors.white.withValues(alpha: 0.8) : AppColors.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/booking/screens/booking_preview_screen.dart b/lib/features/booking/screens/booking_preview_screen.dart deleted file mode 100644 index 8406748..0000000 --- a/lib/features/booking/screens/booking_preview_screen.dart +++ /dev/null @@ -1,253 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/utils/snackbar_utils.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; -import 'package:doormile/features/booking/screens/booking_scaffold.dart'; -import 'package:doormile/features/booking/screens/payment_sheet.dart'; - -class BookingPreviewScreen extends StatefulWidget { - const BookingPreviewScreen({super.key}); - - @override - State createState() => _BookingPreviewScreenState(); -} - -class _BookingPreviewScreenState extends State { - bool _loading = false; - - void _confirmOrder() async { - setState(() => _loading = true); - try { - await context.read().placeOrder(); - if (!mounted) return; - setState(() => _loading = false); - Navigator.pushNamedAndRemoveUntil(context, Routes.bookingSuccess, ModalRoute.withName(Routes.home)); - } catch (e) { - if (!mounted) return; - setState(() => _loading = false); - SnackbarUtils.showError(context, e.toString()); - } - } - - @override - Widget build(BuildContext context) { - final state = context.watch(); - final draft = state.draft; - - return BookingScaffold( - title: 'Review Booking', - step: 4, - stepLabel: 'Step 4: Review', - buttonLabel: 'Confirm booking', - loading: _loading, - enabled: !_loading, - onContinue: _confirmOrder, - children: [ - Center( - child: Column( - children: [ - StatusChip( - label: 'Step 4 of 4', - color: AppColors.primary, - bg: AppColors.primaryFixed.withValues(alpha: 0.5), - ), - const SizedBox(height: 10), - Text('Review Booking', style: AppTheme.headlineLg), - const SizedBox(height: 4), - Text('Please verify your delivery details before confirming payment.', - textAlign: TextAlign.center, - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)), - ], - ), - ), - const SizedBox(height: 20), - // Pickup location - AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.gps_fixed, size: 18, color: AppColors.primary), - const SizedBox(width: 8), - Expanded(child: Text('Pickup Location', style: AppTheme.labelMd)), - SecondaryButton( - label: 'Change', - onPressed: () => Navigator.popUntil( - context, (r) => r.settings.name == Routes.bookingPickup), - ), - ], - ), - const SizedBox(height: 10), - Text(draft.pickupAddress, - style: AppTheme.headlineSm.copyWith(fontWeight: FontWeight.w700)), - Text(draft.dropPincode.isNotEmpty - ? 'Drop details will be collected by Miler at Pincode: ${draft.dropPincode}' - : 'Drop details will be collected by Miler', - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)), - ], - ), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _MiniCard( - icon: Icons.inventory_2, - title: 'Category', - value: draft.parcelType, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _MiniCard( - icon: Icons.speed, - title: 'Service', - value: draft.service, - ), - ), - ], - ), - const SizedBox(height: 12), - AppCard( - border: Border.all(color: AppColors.outlineVariant), - child: Row( - children: [ - const IconBadge( - icon: Icons.local_shipping, - bg: AppColors.primary, - color: Colors.white, - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Doormile Collection', - style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)), - Text('Assigned optimal vehicle size', style: AppTheme.caption), - ], - ), - ), - StatusChip( - label: 'EV First', - icon: Icons.eco, - color: AppColors.tertiary, - bg: AppColors.tertiaryFixed.withValues(alpha: 0.5), - ), - ], - ), - ), - const SizedBox(height: 12), - // Fare summary - AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Estimated Price Range', style: AppTheme.headlineSm), - const SizedBox(height: 14), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Text('Final price determined at pickup', - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant), overflow: TextOverflow.ellipsis), - ), - const SizedBox(width: 12), - Text('₹${draft.minPrice.toInt()} - ₹${draft.maxPrice.toInt()}', - style: AppTheme.headlineMd.copyWith(color: AppColors.primary)), - ], - ), - ], - ), - ), - - const SizedBox(height: 12), - Center( - child: Text('Taxes and fees are included in the total price.', - style: AppTheme.caption), - ), - ], - ); - } - - Widget _fareRow(String label, String value) => Padding( - padding: const EdgeInsets.symmetric(vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Text(label, - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant), - overflow: TextOverflow.ellipsis), - ), - const SizedBox(width: 12), - Text(value, style: AppTheme.labelMd), - ], - ), - ); -} - -class _MiniCard extends StatelessWidget { - final IconData icon; - final String title; - final String value; - final String? subtitle; - final List? chips; - const _MiniCard({ - required this.icon, - required this.title, - required this.value, - this.subtitle, - this.chips, - }); - - @override - Widget build(BuildContext context) { - return AppCard( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - IconBadge( - icon: icon, - bg: AppColors.surfaceContainer, - color: AppColors.primary, - size: 34, - iconSize: 18, - ), - const SizedBox(height: 10), - Text(title, style: AppTheme.caption), - Text(value, style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)), - if (subtitle != null) - Text(subtitle!, style: AppTheme.caption.copyWith(color: AppColors.onSurfaceVariant)), - if (chips != null) ...[ - const SizedBox(height: 6), - Wrap( - spacing: 6, - runSpacing: 6, - children: chips! - .map((c) => Align( - alignment: Alignment.centerLeft, - child: FittedBox( - fit: BoxFit.scaleDown, - child: StatusChip( - label: c, - color: AppColors.secondary, - bg: AppColors.secondaryFixed, - ), - ), - )) - .toList(), - ), - ], - ], - ), - ); - } -} diff --git a/lib/features/booking/screens/booking_scaffold.dart b/lib/features/booking/screens/booking_scaffold.dart deleted file mode 100644 index ea265b7..0000000 --- a/lib/features/booking/screens/booking_scaffold.dart +++ /dev/null @@ -1,147 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; - -/// Shared layout for the multi-step booking flow: app bar, step progress, -/// scrollable body, and a sticky bottom action button. -class BookingScaffold extends StatelessWidget { - final String title; - final int step; // 1..4 - final String stepLabel; - final String buttonLabel; - final VoidCallback onContinue; - final List children; - final Widget? bottomExtra; - final bool enabled; - final bool loading; - - const BookingScaffold({ - super.key, - required this.title, - required this.step, - required this.stepLabel, - required this.buttonLabel, - required this.onContinue, - required this.children, - this.bottomExtra, - this.enabled = true, - this.loading = false, - }); - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.surface, - body: AppBackground( - child: Column( - children: [ - Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [AppColors.primaryContainer, AppColors.primary], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.vertical(bottom: Radius.circular(24)), - ), - child: ClipRRect( - borderRadius: const BorderRadius.vertical(bottom: Radius.circular(24)), - child: Stack( - children: [ - Positioned( - right: -20, - top: 10, - child: Transform.rotate( - angle: 0.1, - child: Icon(Icons.add_location_alt_rounded, size: 140, color: Colors.white.withValues(alpha: 0.04)), - ), - ), - SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 10, 16, 18), - child: Column( - children: [ - Row( - children: [ - IconButton( - onPressed: () => Navigator.pop(context), - icon: const Icon(Icons.arrow_back, color: Colors.white), - ), - Expanded( - child: Text(title, - overflow: TextOverflow.ellipsis, - style: AppTheme.headlineMd.copyWith(color: Colors.white)), - ), - const Icon(Icons.location_on_outlined, color: Colors.white), - ], - ), - const SizedBox(height: 12), - AppCard( - padding: const EdgeInsets.all(14), - color: Colors.white.withValues(alpha: 0.94), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Text(stepLabel, - style: AppTheme.labelMd.copyWith(color: AppColors.primary), - overflow: TextOverflow.ellipsis), - ), - const SizedBox(width: 8), - Text('Step $step', - style: AppTheme.caption.copyWith(fontWeight: FontWeight.w700)), - ], - ), - const SizedBox(height: 10), - JourneyProgressBar(value: 1, gradient: true), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ), - ), - Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 18, 16, 24), - children: children, - ), - ), - ], - ), - ), - bottomNavigationBar: Container( - decoration: BoxDecoration( - color: AppColors.surfaceContainerLowest.withValues(alpha: 0.95), - border: const Border(top: BorderSide(color: AppColors.surfaceContainer)), - ), - child: SafeArea( - top: false, - minimum: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (bottomExtra != null) ...[bottomExtra!, const SizedBox(height: 10)], - PrimaryButton( - label: buttonLabel, - loading: loading, - trailingIcon: Icons.arrow_forward, - height: 54, - onPressed: enabled ? onContinue : null, - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/features/booking/screens/booking_success_screen.dart b/lib/features/booking/screens/booking_success_screen.dart deleted file mode 100644 index 35b4fd9..0000000 --- a/lib/features/booking/screens/booking_success_screen.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; - -class BookingSuccessScreen extends StatefulWidget { - const BookingSuccessScreen({super.key}); - - @override - State createState() => _BookingSuccessScreenState(); -} - -class _BookingSuccessScreenState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _c = - AnimationController(vsync: this, duration: const Duration(milliseconds: 600))..forward(); - - @override - void dispose() { - _c.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final order = context.read().orders.first; - return Scaffold( - backgroundColor: AppColors.surface, - body: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - const Spacer(), - ScaleTransition( - scale: CurvedAnimation(parent: _c, curve: Curves.elasticOut), - child: Container( - width: 110, - height: 110, - decoration: BoxDecoration( - color: AppColors.success.withValues(alpha: 0.12), - shape: BoxShape.circle, - ), - child: const Icon(Icons.check_circle, - color: AppColors.success, size: 72), - ), - ), - const SizedBox(height: 28), - Text('Booking Confirmed!', style: AppTheme.headlineLg), - const SizedBox(height: 8), - Text( - 'Your EV pickup is scheduled. We’ll notify you when the rider is on the way.', - textAlign: TextAlign.center, - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant), - ), - const SizedBox(height: 24), - AppCard( - child: Column( - children: [ - _row('Order ID', order.id), - const Divider(height: 22, color: AppColors.surfaceContainer), - _row('Estimated Fare', '₹${order.amount.toStringAsFixed(0)}'), - const Divider(height: 22, color: AppColors.surfaceContainer), - _row('Pickup Status', 'Waiting for Miler assignment'), - ], - ), - ), - const SizedBox(height: 16), - const EcoBanner( - text: 'This delivery is 100% electric — offsetting ~1.2kg of CO₂.'), - const Spacer(), - PrimaryButton( - label: 'Track Live', - trailingIcon: Icons.navigation, - onPressed: () => Navigator.pushNamedAndRemoveUntil( - context, - Routes.liveTracking, - (r) => r.settings.name == Routes.home, - arguments: order, - ), - ), - const SizedBox(height: 12), - TextButton( - onPressed: () => Navigator.popUntil( - context, (r) => r.settings.name == Routes.home), - child: Text('Back to Home', - style: AppTheme.labelMd.copyWith(color: AppColors.primary)), - ), - ], - ), - ), - ), - ); - } - - Widget _row(String k, String v) => Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(k, style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)), - const SizedBox(width: 12), - Flexible( - child: Text(v, - textAlign: TextAlign.right, - style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)), - ), - ], - ); -} diff --git a/lib/features/booking/screens/parcel_details_screen.dart b/lib/features/booking/screens/parcel_details_screen.dart deleted file mode 100644 index 8873b09..0000000 --- a/lib/features/booking/screens/parcel_details_screen.dart +++ /dev/null @@ -1,349 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; -import 'package:doormile/features/booking/screens/booking_scaffold.dart'; - -class ParcelDetailsScreen extends StatefulWidget { - const ParcelDetailsScreen({super.key}); - - @override - State createState() => _ParcelDetailsScreenState(); -} - -class _ParcelDetailsScreenState extends State { - final _types = const [ - ('Documents', Icons.description), - ('Electronics', Icons.devices), - ('Food', Icons.restaurant), - ('Clothing', Icons.checkroom), - ('Fragile', Icons.auto_awesome), - ('Other', Icons.more_horiz), - ]; - final _weights = const ['0–1 kg', '1–5 kg', '5–10 kg', '10 kg+']; - final _sizes = const [ - ('Small', 'Fits in a backpack (e.g. Phone, Keys)'), - ('Medium', 'Fits in a courier box (e.g. Laptop, Shoes)'), - ('Large', 'Requires vehicle trunk (e.g. Monitor, Luggage)'), - ]; - final _days = const ['Today', 'Tomorrow']; - final _slots = const ['10:00 AM - 12:00 PM', '12:00 PM - 02:00 PM', '02:00 PM - 04:00 PM']; - - @override - Widget build(BuildContext context) { - final draft = context.watch().draft; - return BookingScaffold( - title: 'Parcel Details', - step: 2, - stepLabel: 'Step 2: Details', - buttonLabel: 'Continue', - onContinue: () => Navigator.pushNamed(context, Routes.servicePrice), - children: [ - Text('What are you sending?', style: AppTheme.headlineSm), - const SizedBox(height: 14), - GridView.count( - crossAxisCount: 3, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 1.05, - children: _types.map((t) { - final active = draft.parcelType == t.$1; - return _SelectTile( - active: active, - onTap: () { - draft.parcelType = t.$1; - context.read().touch(); - }, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(t.$2, color: active ? Colors.white : AppColors.primary, size: 26), - const SizedBox(height: 8), - Text(t.$1, - style: AppTheme.labelMd.copyWith( - color: active ? Colors.white : AppColors.onSurface)), - ], - ), - ); - }).toList(), - ), - const SizedBox(height: 24), - Text('Approximate Weight', style: AppTheme.headlineSm), - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: _weights - .map((w) => _Pill( - label: w, - active: draft.weight == w, - onTap: () { - draft.weight = w; - context.read().touch(); - }, - )) - .toList(), - ), - const SizedBox(height: 24), - Text('Package Size', style: AppTheme.headlineSm), - const SizedBox(height: 12), - ..._sizes.map((s) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _RadioCard( - active: draft.size == s.$1, - onTap: () { - draft.size = s.$1; - context.read().touch(); - }, - child: Row( - children: [ - IconBadge( - icon: Icons.inventory_2, - bg: AppColors.primaryContainer.withValues(alpha: 0.1), - color: AppColors.primary, - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(s.$1, - style: AppTheme.labelMd - .copyWith(fontWeight: FontWeight.w700)), - const SizedBox(height: 2), - Text(s.$2, style: AppTheme.caption), - ], - ), - ), - ], - ), - ), - )), - const SizedBox(height: 12), - Text('Pickup Slot', style: AppTheme.headlineSm), - const SizedBox(height: 12), - Row( - children: _days - .map((d) => Padding( - padding: const EdgeInsets.only(right: 8), - child: _Pill( - label: d, - active: draft.pickupDay == d, - onTap: () { - draft.pickupDay = d; - context.read().touch(); - }, - ), - )) - .toList(), - ), - const SizedBox(height: 12), - ..._slots.map((slot) => Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _RadioCard( - active: draft.pickupSlot == slot, - onTap: () { - draft.pickupSlot = slot; - context.read().touch(); - }, - child: Text(slot, - style: AppTheme.bodyMd.copyWith(fontWeight: FontWeight.w700)), - ), - )), - const SizedBox(height: 16), - // Fragile toggle - AppCard( - border: Border.all(color: AppColors.outlineVariant), - child: Row( - children: [ - const Icon(Icons.warning_amber_rounded, color: AppColors.warning), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Mark as Fragile', - style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)), - Text('Handle with extra care', style: AppTheme.caption), - ], - ), - ), - Switch( - value: draft.fragile, - activeTrackColor: AppColors.primary, - onChanged: (v) { - draft.fragile = v; - context.read().touch(); - }, - ), - ], - ), - ), - const SizedBox(height: 16), - Text('Additional Notes (Optional)', - style: AppTheme.labelMd.copyWith(color: AppColors.onSurfaceVariant)), - const SizedBox(height: 8), - TextField( - maxLines: 3, - cursorColor: AppColors.primary, - onChanged: (v) => draft.notes = v, - decoration: InputDecoration( - hintText: 'Gate code, specific handling instructions, etc.', - hintStyle: AppTheme.bodyMd.copyWith(color: AppColors.outline), - filled: true, - fillColor: AppColors.surfaceContainerLowest, - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: const BorderSide(color: AppColors.outlineVariant), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: const BorderSide(color: AppColors.primary), - ), - ), - ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppColors.tertiaryFixedDim.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.tertiary.withValues(alpha: 0.1)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const IconBadge( - icon: Icons.electric_rickshaw, - bg: Color(0x1A005251), - color: AppColors.tertiary, - size: 40, - iconSize: 20, - circle: true, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('EV-First & 3-Wheeler Collection', - style: AppTheme.labelMd - .copyWith(color: AppColors.tertiary, fontWeight: FontWeight.w700)), - const SizedBox(height: 2), - Text( - 'A Doormile agent arrives in a 3-wheeler during your slot. Delivered by EV to cut carbon footprint.', - style: AppTheme.caption - .copyWith(color: AppColors.onTertiaryFixedVariant), - ), - ], - ), - ), - ], - ), - ), - ], - ); - } -} - -// ---- Shared selection widgets ---- - -class _SelectTile extends StatelessWidget { - final bool active; - final Widget child; - final VoidCallback onTap; - const _SelectTile({required this.active, required this.child, required this.onTap}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: BoxDecoration( - color: active ? AppColors.primary : AppColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: active ? AppColors.primary : AppColors.outlineVariant), - ), - child: child, - ), - ); - } -} - -class _Pill extends StatelessWidget { - final String label; - final bool active; - final VoidCallback onTap; - const _Pill({required this.label, required this.active, required this.onTap}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), - decoration: BoxDecoration( - color: active ? AppColors.primary : AppColors.surfaceContainer, - borderRadius: BorderRadius.circular(999), - ), - child: Text(label, - style: AppTheme.labelMd - .copyWith(color: active ? Colors.white : AppColors.onSurface)), - ), - ); - } -} - -class _RadioCard extends StatelessWidget { - final bool active; - final Widget child; - final VoidCallback onTap; - const _RadioCard({required this.active, required this.child, required this.onTap}); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: active ? const Color(0xFFF8E0E3) : AppColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: active ? AppColors.primary : AppColors.outlineVariant, - width: active ? 2 : 1, - ), - ), - child: Row( - children: [ - Expanded(child: child), - const SizedBox(width: 12), - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: active ? AppColors.primary : AppColors.outline, width: 2), - ), - child: active - ? const Center( - child: CircleAvatar(radius: 5, backgroundColor: AppColors.primary)) - : null, - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/booking/screens/payment_sheet.dart b/lib/features/booking/screens/payment_sheet.dart deleted file mode 100644 index 20e333d..0000000 --- a/lib/features/booking/screens/payment_sheet.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/utils/snackbar_utils.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; - -Future showPaymentSheet(BuildContext context, double amount) { - return showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: AppColors.surface, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (_) => _PaymentSheet(amount: amount), - ); -} - -class _Method { - final String name; - final String? sub; - final IconData icon; - final Color iconColor; - const _Method(this.name, this.icon, this.iconColor, {this.sub}); -} - -class _PaymentSheet extends StatefulWidget { - final double amount; - const _PaymentSheet({required this.amount}); - - @override - State<_PaymentSheet> createState() => _PaymentSheetState(); -} - -class _PaymentSheetState extends State<_PaymentSheet> { - final _methods = const [ - _Method('UPI (GPay, PhonePe, Paytm)', Icons.account_balance, AppColors.secondary), - _Method('Credit / Debit Cards', Icons.credit_card, AppColors.onSurface), - _Method('Net banking', Icons.account_balance_outlined, AppColors.onSurface), - _Method('Cash on Delivery', Icons.payments_outlined, AppColors.onSurface), - ]; - int _selected = 0; - bool _processing = false; - - void _pay() async { - setState(() => _processing = true); - final state = context.read(); - state.draft.paymentMethod = _methods[_selected].name; - await Future.delayed(const Duration(milliseconds: 1400)); - if (!mounted) return; - try { - await state.placeOrder(); - if (!mounted) return; - Navigator.pop(context); // close sheet - Navigator.pushNamedAndRemoveUntil( - context, - Routes.bookingSuccess, - (r) => r.settings.name == Routes.home, - ); - } catch (e) { - if (!mounted) return; - setState(() => _processing = false); - SnackbarUtils.showError(context, e.toString()); - } - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 10), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppColors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(99)), - ), - Padding( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Pay ₹${widget.amount.toStringAsFixed(0)}', style: AppTheme.headlineLg), - Text('Order ID: 293810', - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)), - ], - ), - ), - ...List.generate(_methods.length, (i) { - final m = _methods[i]; - final active = i == _selected; - return Padding( - padding: const EdgeInsets.fromLTRB(16, 6, 16, 6), - child: GestureDetector( - onTap: () => setState(() => _selected = i), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: active ? AppColors.surfaceContainer : AppColors.surface, - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: active ? AppColors.primary : AppColors.outlineVariant), - ), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: AppColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(10), - ), - child: Icon(m.icon, color: m.iconColor, size: 22), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(m.name, - style: AppTheme.labelMd - .copyWith(fontWeight: FontWeight.w700)), - if (m.sub != null) - Text(m.sub!, - style: AppTheme.caption.copyWith(color: AppColors.primary)), - ], - ), - ), - Container( - width: 22, - height: 22, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: active ? AppColors.primary : AppColors.outline, - width: 2), - color: active ? AppColors.primary : Colors.transparent, - ), - child: active - ? const Icon(Icons.circle, size: 8, color: Colors.white) - : null, - ), - ], - ), - ), - ), - ); - }), - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), - child: SafeArea( - top: false, - child: PrimaryButton( - label: 'Pay ₹${widget.amount.toStringAsFixed(0)}', - loading: _processing, - height: 54, - onPressed: _pay, - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/booking/screens/service_price_screen.dart b/lib/features/booking/screens/service_price_screen.dart deleted file mode 100644 index e827554..0000000 --- a/lib/features/booking/screens/service_price_screen.dart +++ /dev/null @@ -1,253 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; -import 'package:doormile/features/booking/screens/booking_scaffold.dart'; - -class ServicePriceScreen extends StatelessWidget { - const ServicePriceScreen({super.key}); - - @override - Widget build(BuildContext context) { - final state = context.watch(); - final draft = state.draft; - return BookingScaffold( - title: 'Service & Price', - step: 3, - stepLabel: 'Step 3: Choose Service', - buttonLabel: 'Confirm & Continue', - onContinue: () => Navigator.pushNamed(context, Routes.bookingPreview), - children: [ - Text('CHOOSE SERVICE', - style: AppTheme.caption - .copyWith(letterSpacing: 1.2, fontWeight: FontWeight.w700)), - const SizedBox(height: 12), - _ServiceCard( - active: draft.service == 'Standard', - title: 'Standard', - badge: 'EV-FIRST', - subtitle: 'Today by 6:00 PM', - note: '3-Wheeler Collection included', - price: '₹${draft.minPrice.toStringAsFixed(0)}', - icon: Icons.eco, - iconColor: AppColors.tertiary, - onTap: () { - draft.service = 'Standard'; - state.touch(); - }, - ), - const SizedBox(height: 12), - _ServiceCard( - active: draft.service == 'Express', - title: 'Express', - subtitle: 'Arrival in 2 hrs', - price: '₹${draft.maxPrice.toStringAsFixed(0)}', - icon: Icons.bolt, - iconColor: AppColors.secondary, - onTap: () { - draft.service = 'Express'; - state.touch(); - }, - ), - const SizedBox(height: 20), - // Selected slot - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.surfaceContainerLow, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.outlineVariant), - ), - child: Row( - children: [ - const Icon(Icons.calendar_month, color: AppColors.primary), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('SELECTED SLOT', - style: AppTheme.caption - .copyWith(letterSpacing: 1, fontWeight: FontWeight.w600)), - Text('Pickup: ${draft.pickupDay}, ${draft.pickupSlot}', - style: AppTheme.labelMd), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 16), - // Fare breakdown - AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('ESTIMATED FARE', - style: AppTheme.caption.copyWith( - color: AppColors.onSurfaceVariant, - letterSpacing: 1.2, - fontWeight: FontWeight.w700)), - const SizedBox(height: 14), - _fareRow('Estimated Range', '₹${draft.minPrice.toStringAsFixed(0)} - ₹${draft.maxPrice.toStringAsFixed(0)}'), - const Divider(height: 24, color: AppColors.surfaceContainerHigh), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Final price determined at pickup', style: AppTheme.caption), - ], - ), - ], - ), - ), - const SizedBox(height: 16), - // Pay-by row - AppCard( - onTap: () {}, - child: Row( - children: [ - const IconBadge( - icon: Icons.account_balance_wallet, - bg: AppColors.surfaceContainer, - color: AppColors.primary, - size: 36, - iconSize: 18, - circle: true, - ), - const SizedBox(width: 12), - Expanded(child: Text('Pay by: UPI', style: AppTheme.labelMd)), - const Icon(Icons.expand_more, color: AppColors.onSurfaceVariant), - ], - ), - ), - const SizedBox(height: 12), - const Row( - children: [ - Icon(Icons.shield_outlined, size: 16, color: AppColors.tertiary), - SizedBox(width: 6), - Text('Secured by Doormile Pay Protection', - style: TextStyle( - fontSize: 12, color: AppColors.onSurfaceVariant)), - ], - ), - ], - ); - } - - Widget _fareRow(String label, String value) => Padding( - padding: const EdgeInsets.symmetric(vertical: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)), - Text(value, style: AppTheme.labelMd), - ], - ), - ); -} - -class _ServiceCard extends StatelessWidget { - final bool active; - final String title; - final String? badge; - final String subtitle; - final String? note; - final String price; - final IconData icon; - final Color iconColor; - final VoidCallback onTap; - - const _ServiceCard({ - required this.active, - required this.title, - this.badge, - required this.subtitle, - this.note, - required this.price, - required this.icon, - required this.iconColor, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: active ? const Color(0xFFFBE3E6) : AppColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: active ? AppColors.primary : AppColors.outlineVariant, - width: active ? 2 : 1, - ), - boxShadow: active ? null : AppTheme.cardShadow, - ), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, color: iconColor), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text(title, - style: AppTheme.headlineSm, overflow: TextOverflow.ellipsis), - ), - if (badge != null) ...[ - const SizedBox(width: 8), - StatusChip( - label: badge!, - color: AppColors.tertiary, - bg: AppColors.tertiaryFixed.withValues(alpha: 0.5), - ), - ], - ], - ), - const SizedBox(height: 2), - Text(subtitle, style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)), - if (note != null) - Text(note!, - style: AppTheme.caption.copyWith( - color: AppColors.tertiary, fontWeight: FontWeight.w600)), - ], - ), - ), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - if (active) - const CircleAvatar( - radius: 11, - backgroundColor: AppColors.primary, - child: Icon(Icons.check, size: 14, color: Colors.white), - ), - const SizedBox(height: 6), - Text(price, - style: AppTheme.headlineSm.copyWith(color: AppColors.primary)), - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/dashboard/screens/home_screen.dart b/lib/features/dashboard/screens/home_screen.dart deleted file mode 100644 index d9b9feb..0000000 --- a/lib/features/dashboard/screens/home_screen.dart +++ /dev/null @@ -1,475 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/shared/models/models.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; -import 'package:doormile/core/widgets/logo.dart'; - -class HomeScreen extends StatefulWidget { - const HomeScreen({super.key}); - - @override - State createState() => _HomeScreenState(); -} - -class _HomeScreenState extends State { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().fetchOrders(); - }); - } - - @override - Widget build(BuildContext context) { - final state = context.watch(); - final active = state.orders.where((o) => o.status == OrderStatus.active).firstOrNull; - - return AppBackground( - child: Stack( - children: [ - SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 100), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _hero(context, state), - const SizedBox(height: 26), - active != null ? _activeShipment(context, active) : _emptyShipment(context), - const SizedBox(height: 18), - _ecoBanner(), - ], - ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: Container( - height: MediaQuery.of(context).padding.top, - color: AppColors.primaryContainer, - ), - ), - ], - ), - ); - } - - Widget _hero(BuildContext context, AppState state) { - return Stack( - clipBehavior: Clip.none, - children: [ - Container( - height: 196, - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [AppColors.primaryContainer, AppColors.primary], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.vertical(bottom: Radius.circular(24)), - ), - child: ClipRRect( - borderRadius: const BorderRadius.vertical(bottom: Radius.circular(24)), - child: AnnotatedRegion( - value: SystemUiOverlayStyle.light, - child: Stack( - children: [ - Positioned( - right: -20, - top: 20, - child: Transform.rotate( - angle: -0.1, - child: Icon(Icons.local_shipping_rounded, size: 150, color: Colors.white.withValues(alpha: 0.04)), - ), - ), - Positioned( - left: 40, - bottom: -30, - child: Transform.rotate( - angle: 0.15, - child: Icon(Icons.route_rounded, size: 100, color: Colors.white.withValues(alpha: 0.03)), - ), - ), - SafeArea( - bottom: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const DoormileMark(size: 48, color: Colors.white, glyphColor: AppColors.primary), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.location_on_rounded, color: Colors.white70, size: 15), - const SizedBox(width: 4), - Text('Bengaluru', style: AppTheme.labelMd.copyWith(color: Colors.white)), - ], - ), - const SizedBox(height: 4), - Text( - 'Hi, ${state.firstName.isEmpty ? 'Admin' : state.firstName}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTheme.headlineMd.copyWith(color: Colors.white), - ), - Text( - 'Let deliveries move with less friction', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTheme.caption.copyWith(color: Colors.white70), - ), - ], - ), - ), - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.16), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.white24), - ), - child: const Icon(Icons.notifications_none_rounded, color: Colors.white), - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), - Positioned( - left: 20, - right: 20, - top: 130, - child: AppCard( - padding: const EdgeInsets.all(18), - radius: BorderRadius.circular(24), - border: Border.all(color: Colors.white), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Send a parcel', style: AppTheme.headlineLg), - const SizedBox(height: 6), - Text( - 'Fast pickup, live tracking, EV-first delivery.', - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant), - ), - const SizedBox(height: 16), - SizedBox( - height: 44, - child: ElevatedButton.icon( - onPressed: () { - final draft = context.read().draft; - draft.pickupAddress = 'Current Location (GPS)'; - Navigator.pushNamed(context, Routes.bookingPickup); - }, - icon: const Icon(Icons.arrow_forward_rounded, size: 18), - label: const Text('Book Now'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - elevation: 0, - padding: const EdgeInsets.symmetric(horizontal: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - ), - ), - ), - ], - ), - ), - const SizedBox(width: 10), - SizedBox( - width: 90, - height: 90, - child: Stack( - alignment: Alignment.center, - children: [ - const Icon(Icons.local_shipping_rounded, size: 68, color: AppColors.primary), - Positioned( - right: 0, - top: 0, - child: IconBadge( - icon: Icons.eco_rounded, - bg: AppColors.tertiaryContainer, - color: AppColors.tertiary, - size: 28, - iconSize: 16, - circle: true, - ), - ), - ], - ), - ), - ], - ), - ), - ), - const SizedBox(height: 310), - ], - ); - } - - Widget _activeShipment(BuildContext context, Order active) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Live Shipment', style: AppTheme.headlineSm.copyWith(fontWeight: FontWeight.w800)), - TextButton( - onPressed: () => Navigator.pushNamed(context, Routes.myOrders), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - child: Text('View Details', style: AppTheme.labelMd.copyWith(color: AppColors.primary)), - ), - ], - ), - const SizedBox(height: 14), - GestureDetector( - onTap: () => Navigator.pushNamed(context, Routes.liveTracking, arguments: active), - child: Container( - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.primary, AppColors.secondary], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow(color: AppColors.primary.withValues(alpha: 0.3), blurRadius: 24, offset: const Offset(0, 10)), - ], - ), - padding: const EdgeInsets.all(2), // Gradient border width - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(22), - ), - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.primaryFixed, - borderRadius: BorderRadius.circular(16), - ), - child: const Icon(Icons.inventory_2_rounded, color: AppColors.primary, size: 28), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: AppColors.primary, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - Text('PENDING PICKUP', style: AppTheme.caption.copyWith(color: AppColors.primary, fontWeight: FontWeight.w800, letterSpacing: 1.2)), - ], - ), - const SizedBox(height: 4), - Text(active.id, style: AppTheme.headlineSm), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: AppColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(999), - border: Border.all(color: AppColors.outlineVariant), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('Live', style: TextStyle(color: AppColors.onSurface, fontWeight: FontWeight.w600, fontSize: 12)), - SizedBox(width: 4), - Icon(Icons.arrow_forward_ios_rounded, size: 10, color: AppColors.onSurfaceVariant), - ], - ), - ), - ], - ), - const SizedBox(height: 24), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.surfaceContainerHigh), - ), - child: Row( - children: [ - const Column( - children: [ - Icon(Icons.my_location_rounded, size: 20, color: AppColors.primary), - SizedBox(height: 4), - SizedBox( - height: 24, - child: VerticalDivider(color: AppColors.outlineVariant, thickness: 2), - ), - SizedBox(height: 4), - Icon(Icons.pin_drop_rounded, size: 20, color: AppColors.tertiary), - ], - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Pickup Location', style: AppTheme.caption), - Text(active.fromLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: AppTheme.labelMd), - const SizedBox(height: 20), - Text('Destination', style: AppTheme.caption), - Text(active.toLabel, maxLines: 1, overflow: TextOverflow.ellipsis, style: AppTheme.labelMd), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _trackNode(String title, String subtitle, bool active) { - return Expanded( - child: Column( - children: [ - Text(title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTheme.caption.copyWith( - color: active ? AppColors.secondary : AppColors.onSurfaceVariant, - fontWeight: FontWeight.w700, - )), - const SizedBox(height: 2), - Text(subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: AppTheme.caption), - ], - ), - ); - } - - Widget _emptyShipment(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SectionHeader(title: 'Active Shipment'), - const SizedBox(height: 12), - AppCard( - padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 18), - child: Column( - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [AppColors.primary.withValues(alpha: 0.1), AppColors.primary.withValues(alpha: 0.25)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.15), - blurRadius: 20, - spreadRadius: 2, - offset: const Offset(0, 8), - ), - ], - ), - child: Center( - child: Container( - width: 54, - height: 54, - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.inbox_rounded, - size: 28, - color: AppColors.primary, - ), - ), - ), - ), - const SizedBox(height: 14), - Text('No active shipments', style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w800)), - const SizedBox(height: 4), - Text( - 'You do not have any deliveries in progress right now.', - textAlign: TextAlign.center, - style: AppTheme.caption, - ), - const SizedBox(height: 16), - SecondaryButton( - label: 'Book a Shipment', - onPressed: () { - final draft = context.read().draft; - draft.pickupAddress = 'Current Location (GPS)'; - Navigator.pushNamed(context, Routes.bookingPickup); - }, - ), - ], - ), - ), - ], - ), - ); - } - - Widget _ecoBanner() { - return const Padding( - padding: EdgeInsets.symmetric(horizontal: 20), - child: EcoBanner(text: 'This delivery offsets 1.2kg of CO2 emissions.'), - ); - } - -} diff --git a/lib/features/dashboard/screens/main_shell.dart b/lib/features/dashboard/screens/main_shell.dart deleted file mode 100644 index 7bd0643..0000000 --- a/lib/features/dashboard/screens/main_shell.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; -import 'package:permission_handler/permission_handler.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/features/dashboard/screens/home_screen.dart'; -import 'package:doormile/features/tracking/screens/my_orders_screen.dart'; -import 'package:doormile/features/dashboard/screens/summary_screen.dart'; -import 'package:doormile/features/dashboard/screens/profile_screen.dart'; - -/// Root scaffold hosting the 5-tab bottom navigation. -class MainShell extends StatefulWidget { - final int initialIndex; - const MainShell({super.key, this.initialIndex = 0}); - - @override - State createState() => _MainShellState(); -} - -class _MainShellState extends State { - late int _index = widget.initialIndex; - - @override - void initState() { - super.initState(); - _requestPermissions(); - } - - Future _requestPermissions() async { - if (kIsWeb) return; - try { - final status = await Permission.locationWhenInUse.status; - if (!status.isGranted) { - await Permission.locationWhenInUse.request(); - } - } catch (e) { - debugPrint('Location permission error: $e'); - } - } - - // Tab indices: 0 Home, 1 Track, 3 Wallet, 4 Profile. 2 = Book (action). - final _pages = const [ - HomeScreen(), - MyOrdersScreen(embedded: true), - SizedBox.shrink(), // Book slot — never shown (navigates away) - SummaryScreen(), - ProfileScreen(), - ]; - - void _onTap(int i) { - if (i == 2) { - Navigator.pushNamed(context, Routes.bookingPickup); - return; - } - setState(() => _index = i); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.surface, - body: IndexedStack(index: _index, children: _pages), - extendBody: true, - bottomNavigationBar: _BottomBar(index: _index, onTap: _onTap), - ); - } -} - -class _BottomBar extends StatelessWidget { - final int index; - final ValueChanged onTap; - const _BottomBar({required this.index, required this.onTap}); - - static const _items = [ - (_NavItem(Icons.home_rounded, Icons.home_outlined, 'Home')), - (_NavItem(Icons.map_rounded, Icons.map_outlined, 'Track')), - (_NavItem(Icons.add_circle, Icons.add_circle, 'Book')), - (_NavItem(Icons.bar_chart_rounded, Icons.bar_chart_outlined, 'Summary')), - (_NavItem(Icons.person_rounded, Icons.person_outline, 'Profile')), - ]; - - @override - Widget build(BuildContext context) { - return SafeArea( - top: false, - minimum: const EdgeInsets.fromLTRB(14, 0, 14, 12), - child: Container( - height: 72, - decoration: BoxDecoration( - color: AppColors.surfaceContainerLowest.withValues(alpha: 0.96), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: Colors.white), - boxShadow: AppTheme.cardShadow, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: List.generate(_items.length, (i) { - final item = _items[i]; - final active = i == index; - if (i == 2) { - // Centre "Book" action button. - return _BookButton(onTap: () => onTap(2)); - } - return Expanded( - child: InkWell( - onTap: () => onTap(i), - borderRadius: BorderRadius.circular(18), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedContainer( - duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - decoration: BoxDecoration( - color: active ? AppColors.primaryFixed : Colors.transparent, - borderRadius: BorderRadius.circular(999), - ), - child: Icon(active ? item.active : item.inactive, - color: active ? AppColors.primary : AppColors.onSurfaceVariant, - size: 22), - ), - const SizedBox(height: 2), - Text(item.label, - maxLines: 1, - style: AppTheme.caption.copyWith( - color: active ? AppColors.primary : AppColors.onSurfaceVariant, - fontWeight: active ? FontWeight.w700 : FontWeight.w500, - )), - ], - ), - ), - ); - }), - ), - ), - ), - ); - } -} - -class _BookButton extends StatelessWidget { - final VoidCallback onTap; - const _BookButton({required this.onTap}); - - @override - Widget build(BuildContext context) { - return Expanded( - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(40), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 46, - height: 46, - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.primaryContainer, AppColors.primary], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.28), - blurRadius: 16, - offset: const Offset(0, 8), - ) - ], - ), - child: const Icon(Icons.add, color: Colors.white, size: 28), - ), - const SizedBox(height: 2), - Text('Book', - style: AppTheme.caption.copyWith( - color: AppColors.primary, fontWeight: FontWeight.w700)), - ], - ), - ), - ); - } -} - -class _NavItem { - final IconData active; - final IconData inactive; - final String label; - const _NavItem(this.active, this.inactive, this.label); -} diff --git a/lib/features/dashboard/screens/profile_screen.dart b/lib/features/dashboard/screens/profile_screen.dart deleted file mode 100644 index f887744..0000000 --- a/lib/features/dashboard/screens/profile_screen.dart +++ /dev/null @@ -1,264 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; -import 'package:doormile/core/widgets/logo.dart'; - -class ProfileScreen extends StatelessWidget { - const ProfileScreen({super.key}); - - @override - Widget build(BuildContext context) { - final state = context.watch(); - return AppBackground( - child: Column( - children: [ - const ImmersiveHeader( - title: 'My Profile', - subtitle: 'Addresses, support, and preferences', - icon: Icons.person_rounded, - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 100), - child: Column( - children: [ - _buildProfileAvatar(context, state), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - children: [ - const SizedBox(height: 20), - Row( - children: [ - Expanded( - child: _ProfileStatCard( - value: '${state.orders.length * 4 + 2}', - label: 'Parcels', - icon: Icons.inventory_2_rounded, - color: AppColors.primary, - ), - ), - const SizedBox(width: 16), - Expanded( - child: _ProfileStatCard( - value: '4.9', - label: 'Rating', - icon: Icons.star_rounded, - color: Colors.amber, - ), - ), - ], - ), - const SizedBox(height: 16), - _tile(context, Icons.location_on_outlined, 'My addresses', - onTap: () => Navigator.pushNamed(context, Routes.addresses)), - _tile(context, Icons.notifications_outlined, 'Notifications', dot: true), - _tile(context, Icons.card_giftcard_outlined, 'Refer & earn'), - _tile(context, Icons.language_outlined, 'Language', sub: 'English'), - _tile(context, Icons.help_outline, 'Help & support', - onTap: () => Navigator.pushNamed(context, Routes.help)), - const SizedBox(height: 4), - AppCard( - onTap: () => _confirmLogout(context, state), - child: Row( - children: [ - const Icon(Icons.logout, color: AppColors.primary), - const SizedBox(width: 16), - Text('Logout', - style: AppTheme.labelMd.copyWith( - color: AppColors.primary, fontWeight: FontWeight.w700)), - ], - ), - ), - const SizedBox(height: 24), - Center( - child: Column( - children: [ - Text('Doormile Customers V1.0.0', style: AppTheme.caption), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), -], - ), - ); - } - - Widget _buildProfileAvatar(BuildContext context, AppState state) { - return Column( - children: [ - const SizedBox(height: 24), - Center( - child: Column( - children: [ - Stack( - children: [ - Container( - width: 100, - height: 100, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 4), - color: Colors.white, - boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 10)], - ), - child: ClipOval( - child: Image.asset( - 'assets/images/profileicon.png', - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Icon(Icons.person, size: 50, color: Color(0xFFC60018)), - ), - ), - ), - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 32, - height: 32, - decoration: const BoxDecoration(color: Color(0xFFC60018), shape: BoxShape.circle), - child: const Icon(Icons.edit, size: 16, color: Colors.white), - ), - ), - ], - ), - const SizedBox(height: 16), - Text(state.userName.isEmpty ? 'Admin User' : state.userName, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.black87)), - Text('+91 ${state.phone.isEmpty ? '98765 43210' : state.phone}', style: const TextStyle(fontSize: 14, color: Colors.black54)), - ], - ), - ), - ], - ); - } - - Widget _stat(String value, String label, Color color) { - return const SizedBox.shrink(); // Replaced by _ProfileStatCard - } - - Widget _tile(BuildContext context, IconData icon, String title, - {String? sub, bool dot = false, VoidCallback? onTap}) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: AppCard( - onTap: onTap ?? () {}, - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, color: AppColors.primary, size: 20), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: AppTheme.labelMd), - if (sub != null) ...[ - const SizedBox(height: 2), - Text(sub, style: AppTheme.caption.copyWith(color: AppColors.onSurfaceVariant)), - ] - ], - ), - ), - if (dot) - Container( - width: 8, - height: 8, - margin: const EdgeInsets.only(right: 8), - decoration: const BoxDecoration( - color: AppColors.error, shape: BoxShape.circle), - ), - const Icon(Icons.chevron_right_rounded, color: AppColors.onSurfaceVariant), - ], - ), - ), - ); - } - - void _confirmLogout(BuildContext context, AppState state) { - showDialog( - context: context, - builder: (_) => AlertDialog( - backgroundColor: AppColors.surfaceContainerLowest, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: Text('Log out?', style: AppTheme.headlineSm), - content: Text('You can sign back in anytime with your number.', - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text('Cancel', - style: AppTheme.labelMd.copyWith(color: AppColors.onSurfaceVariant)), - ), - TextButton( - onPressed: () { - state.logout(); - Navigator.pop(context); - Navigator.pushNamedAndRemoveUntil(context, Routes.login, (r) => false); - }, - child: Text('Logout', style: AppTheme.labelMd.copyWith(color: AppColors.primary)), - ), - ], - ), - ); - } -} - -class _ProfileStatCard extends StatelessWidget { - final String value; - final String label; - final IconData icon; - final Color color; - - const _ProfileStatCard({required this.value, required this.label, required this.icon, required this.color}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.surfaceContainerHigh, width: 1), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 10, offset: const Offset(0, 4)), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, color: color, size: 20), - ), - const SizedBox(height: 16), - Text(label, style: AppTheme.caption.copyWith(fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - Text(value, style: AppTheme.headlineMd.copyWith(color: AppColors.onSurface)), - ], - ), - ); - } -} diff --git a/lib/features/dashboard/screens/saved_addresses_screen.dart b/lib/features/dashboard/screens/saved_addresses_screen.dart deleted file mode 100644 index 392b38b..0000000 --- a/lib/features/dashboard/screens/saved_addresses_screen.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; - -class SavedAddressesScreen extends StatefulWidget { - const SavedAddressesScreen({super.key}); - - @override - State createState() => _SavedAddressesScreenState(); -} - -class _SavedAddressesScreenState extends State { - final List> _addresses = [ - { - 'title': 'Home', - 'address': '12th Main, Indiranagar, Bengaluru, Karnataka 560038', - 'icon': Icons.home_rounded, - }, - { - 'title': 'Office', - 'address': 'Sector 4, HSR Layout, Bengaluru, Karnataka 560102', - 'icon': Icons.business_rounded, - }, - { - 'title': "Mom's House", - 'address': 'Jayanagar 4th Block, Bengaluru, Karnataka 560011', - 'icon': Icons.favorite_rounded, - }, - ]; - - void _showAddAddressSheet() { - final titleCtrl = TextEditingController(); - final addressCtrl = TextEditingController(); - - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - decoration: const BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Add New Address', style: AppTheme.headlineSm), - IconButton( - icon: const Icon(Icons.close_rounded), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 24), - Text('LABEL (e.g. Gym)', style: AppTheme.caption.copyWith(letterSpacing: 1)), - const SizedBox(height: 8), - TextField( - controller: titleCtrl, - decoration: InputDecoration( - hintText: 'Enter label...', - filled: true, - fillColor: AppColors.surfaceContainerLowest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: AppColors.surfaceContainer), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: AppColors.surfaceContainer), - ), - ), - ), - const SizedBox(height: 16), - Text('FULL ADDRESS', style: AppTheme.caption.copyWith(letterSpacing: 1)), - const SizedBox(height: 8), - TextField( - controller: addressCtrl, - maxLines: 3, - decoration: InputDecoration( - hintText: 'Enter complete address...', - filled: true, - fillColor: AppColors.surfaceContainerLowest, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: AppColors.surfaceContainer), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide(color: AppColors.surfaceContainer), - ), - ), - ), - const SizedBox(height: 24), - PrimaryButton( - label: 'Save Address', - onPressed: () { - if (titleCtrl.text.trim().isNotEmpty && addressCtrl.text.trim().isNotEmpty) { - setState(() { - _addresses.add({ - 'title': titleCtrl.text.trim(), - 'address': addressCtrl.text.trim(), - 'icon': Icons.location_on_rounded, // default icon - }); - }); - Navigator.pop(context); - } - }, - ), - ], - ), - ), - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.surface, - body: AppBackground( - child: Column( - children: [ - const ImmersiveHeader( - title: 'Saved Addresses', - subtitle: 'Manage your pickup & drop-off locations', - icon: Icons.location_on_rounded, - showBack: true, - ), - Expanded( - child: ListView.separated( - padding: const EdgeInsets.all(20), - itemCount: _addresses.length + 1, - separatorBuilder: (_, __) => const SizedBox(height: 16), - itemBuilder: (context, i) { - if (i == _addresses.length) { - return Padding( - padding: const EdgeInsets.only(top: 8), - child: PrimaryButton( - label: 'Add New Address', - trailingIcon: Icons.add_rounded, - onPressed: _showAddAddressSheet, - ), - ); - } - final item = _addresses[i]; - return _AddressCard( - title: item['title'], - address: item['address'], - icon: item['icon'], - ); - }, - ), - ), - ], - ), - ), - ); - } -} - -class _AddressCard extends StatelessWidget { - final String title; - final String address; - final IconData icon; - - const _AddressCard({required this.title, required this.address, required this.icon}); - - @override - Widget build(BuildContext context) { - return AppCard( - padding: const EdgeInsets.all(16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(14), - ), - child: Icon(icon, color: AppColors.primary, size: 24), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(title, style: AppTheme.labelMd), - const Icon(Icons.more_horiz_rounded, color: AppColors.onSurfaceVariant, size: 20), - ], - ), - const SizedBox(height: 4), - Text(address, style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/dashboard/screens/summary_screen.dart b/lib/features/dashboard/screens/summary_screen.dart deleted file mode 100644 index 44d7f48..0000000 --- a/lib/features/dashboard/screens/summary_screen.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; - -class SummaryScreen extends StatelessWidget { - const SummaryScreen({super.key}); - - @override - Widget build(BuildContext context) { - final state = context.watch(); - - // Dynamically calculate stats from real orders - final totalDeliveries = state.orders.length; - final totalSpent = state.orders.fold(0, (sum, order) => sum + order.amount); - - return AppBackground( - child: Column( - children: [ - const ImmersiveHeader( - title: 'Summary', - subtitle: 'Your delivery and eco-impact stats', - icon: Icons.bar_chart_rounded, - ), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 100), - child: Column( - children: [ - const SizedBox(height: 24), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionHeader(title: 'Overview', action: 'This Month', onAction: () {}), - const SizedBox(height: 12), - Row( - children: [ - _StatCard( - title: 'Total Deliveries', - value: '$totalDeliveries', - icon: Icons.inventory_2_rounded, - color: AppColors.primary, - ), - const SizedBox(width: 16), - _StatCard( - title: 'Total Spent', - value: '₹${totalSpent.toStringAsFixed(0)}', - icon: Icons.payments_rounded, - color: AppColors.tertiary, - ), - ], - ), - - const SizedBox(height: 24), - SectionHeader(title: 'Top Locations', action: 'Manage', onAction: () => Navigator.pushNamed(context, '/addresses')), - const SizedBox(height: 12), - AppCard( - onTap: () => Navigator.pushNamed(context, '/addresses'), - padding: const EdgeInsets.all(0), - child: Column( - children: [ - _LocationRow(title: 'Home', subtitle: 'Indiranagar, Bengaluru', isFirst: true), - const Divider(height: 1, color: AppColors.surfaceContainer), - _LocationRow(title: 'Office', subtitle: 'HSR Layout, Bengaluru', isFirst: false), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ); - } -} - -class _StatCard extends StatelessWidget { - final String title; - final String value; - final IconData icon; - final Color color; - - const _StatCard({required this.title, required this.value, required this.icon, required this.color}); - - @override - Widget build(BuildContext context) { - return Expanded( - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.surfaceContainerHigh, width: 1), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 10, offset: const Offset(0, 4)), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(icon, color: color, size: 20), - ), - const SizedBox(height: 16), - Text(title, style: AppTheme.caption.copyWith(fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - Text(value, style: AppTheme.headlineMd.copyWith(color: AppColors.onSurface)), - ], - ), - ), - ); - } -} - - - -class _LocationRow extends StatelessWidget { - final String title; - final String subtitle; - final bool isFirst; - const _LocationRow({required this.title, required this.subtitle, required this.isFirst}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.fromLTRB(16, isFirst ? 16 : 12, 16, isFirst ? 12 : 16), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.surfaceContainerLowest, - shape: BoxShape.circle, - border: Border.all(color: AppColors.surfaceContainerHigh), - ), - child: const Icon(Icons.business_rounded, size: 20, color: AppColors.primary), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w600)), - const SizedBox(height: 2), - Text(subtitle, style: AppTheme.caption), - ], - ), - ), - const Icon(Icons.chevron_right_rounded, color: AppColors.onSurfaceVariant), - ], - ), - ); - } -} diff --git a/lib/features/support/screens/chat_screen.dart b/lib/features/support/screens/chat_screen.dart deleted file mode 100644 index 7b03b08..0000000 --- a/lib/features/support/screens/chat_screen.dart +++ /dev/null @@ -1,300 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; - -enum _ConnStatus { connecting, connected, disconnected } - -class _ChatMessage { - final String text; - final bool fromCustomer; - _ChatMessage(this.text, {required this.fromCustomer}); -} - -class ChatScreen extends StatefulWidget { - final String riderName; - final String bookingId; - final String? authToken; - - const ChatScreen({ - super.key, - required this.riderName, - required this.bookingId, - this.authToken, - }); - - @override - State createState() => _ChatScreenState(); -} - -class _ChatScreenState extends State { - final TextEditingController _messageController = TextEditingController(); - final ScrollController _scrollController = ScrollController(); - final List<_ChatMessage> _messages = []; - - WebSocketChannel? _channel; - _ConnStatus _status = _ConnStatus.connecting; - - @override - void initState() { - super.initState(); - _connectWebSocket(); - } - - void _connectWebSocket() { - if (widget.bookingId.isEmpty) { - setState(() => _status = _ConnStatus.disconnected); - return; - } - - setState(() => _status = _ConnStatus.connecting); - - try { - String wsUrl = 'ws://api.doormile.com/ws/bookings/${widget.bookingId}/chat?role=customer'; - if (widget.authToken != null && widget.authToken!.isNotEmpty) { - wsUrl += '&token=${widget.authToken}'; - } - - _channel = WebSocketChannel.connect( - Uri.parse(wsUrl), - ); - - // Trigger connected status on first successful frame after connect - _channel!.ready.then((_) { - if (mounted) setState(() => _status = _ConnStatus.connected); - }).catchError((e) { - debugPrint('WS ready error: $e'); - if (mounted) setState(() => _status = _ConnStatus.disconnected); - }); - - _channel!.stream.listen( - _onMessage, - onError: (e) { - debugPrint('WS error: $e'); - if (mounted) setState(() => _status = _ConnStatus.disconnected); - }, - onDone: () { - if (mounted) setState(() => _status = _ConnStatus.disconnected); - }, - cancelOnError: false, - ); - } catch (e) { - debugPrint('WS connect error: $e'); - if (mounted) setState(() => _status = _ConnStatus.disconnected); - } - } - - void _onMessage(dynamic raw) { - try { - final data = jsonDecode(raw as String) as Map; - final text = data['text'] as String? ?? ''; - final sender = data['sender'] as String? ?? ''; - if (text.isEmpty) return; - - if (mounted) { - setState(() { - _messages.add(_ChatMessage(text, fromCustomer: sender == 'customer')); - }); - _scrollToBottom(); - } - } catch (e) { - debugPrint('WS parse error: $e'); - } - } - - void _sendMessage() { - final text = _messageController.text.trim(); - if (text.isEmpty) return; - - final payload = jsonEncode({'text': text, 'sender': 'customer'}); - - if (_channel != null && _status == _ConnStatus.connected) { - _channel!.sink.add(payload); - } - - // Optimistically show the message regardless of WS state - setState(() { - _messages.add(_ChatMessage(text, fromCustomer: true)); - _messageController.clear(); - }); - _scrollToBottom(); - } - - void _scrollToBottom() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - ); - } - }); - } - - @override - void dispose() { - _messageController.dispose(); - _scrollController.dispose(); - _channel?.sink.close(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.surface, - appBar: AppBar( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(widget.riderName, - style: AppTheme.headlineSm.copyWith(color: Colors.white, fontSize: 18)), - Row( - children: [ - Container( - width: 8, - height: 8, - margin: const EdgeInsets.only(right: 6), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: switch (_status) { - _ConnStatus.connected => const Color(0xFF4CAF50), - _ConnStatus.connecting => const Color(0xFFFF9800), - _ConnStatus.disconnected => const Color(0xFFEF5350), - }, - ), - ), - Text( - switch (_status) { - _ConnStatus.connected => 'Connected', - _ConnStatus.connecting => 'Connecting...', - _ConnStatus.disconnected => 'Disconnected', - }, - style: AppTheme.caption.copyWith(color: Colors.white70), - ), - ], - ), - ], - ), - actions: [ - if (_status == _ConnStatus.disconnected) - IconButton( - icon: const Icon(Icons.refresh_rounded), - tooltip: 'Reconnect', - onPressed: () { - _channel?.sink.close(); - _connectWebSocket(); - }, - ), - ], - ), - body: Column( - children: [ - Expanded( - child: _messages.isEmpty - ? Center( - child: Text( - _status == _ConnStatus.connecting - ? 'Connecting to chat...' - : 'No messages yet. Say hi!', - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant), - ), - ) - : ListView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(16), - itemCount: _messages.length, - itemBuilder: (context, index) { - final msg = _messages[index]; - return Align( - alignment: msg.fromCustomer - ? Alignment.centerRight - : Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only(bottom: 12), - constraints: const BoxConstraints(maxWidth: 280), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: msg.fromCustomer - ? AppColors.primary - : AppColors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16).copyWith( - bottomLeft: msg.fromCustomer - ? const Radius.circular(16) - : Radius.zero, - bottomRight: msg.fromCustomer - ? Radius.zero - : const Radius.circular(16), - ), - ), - child: Text( - msg.text, - style: AppTheme.bodyMd.copyWith( - color: msg.fromCustomer - ? Colors.white - : AppColors.onSurface, - ), - ), - ), - ); - }, - ), - ), - Container( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 16, - bottom: MediaQuery.of(context).padding.bottom + 16, - ), - decoration: const BoxDecoration( - color: Colors.white, - border: Border(top: BorderSide(color: AppColors.surfaceContainer)), - ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _messageController, - textInputAction: TextInputAction.send, - onSubmitted: (_) => _sendMessage(), - decoration: InputDecoration( - hintText: 'Message ${widget.riderName}...', - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(999), - borderSide: BorderSide.none, - ), - filled: true, - fillColor: AppColors.surfaceContainerLowest, - contentPadding: - const EdgeInsets.symmetric(horizontal: 20, vertical: 14), - ), - ), - ), - const SizedBox(width: 12), - InkWell( - onTap: _sendMessage, - borderRadius: BorderRadius.circular(999), - child: Container( - padding: const EdgeInsets.all(14), - decoration: const BoxDecoration( - color: AppColors.primary, - shape: BoxShape.circle, - ), - child: const Icon(Icons.send_rounded, color: Colors.white, size: 20), - ), - ), - ], - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/support/screens/help_support_screen.dart b/lib/features/support/screens/help_support_screen.dart deleted file mode 100644 index 99c4b39..0000000 --- a/lib/features/support/screens/help_support_screen.dart +++ /dev/null @@ -1,258 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:doormile/shared/models/models.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; - -class HelpSupportScreen extends StatefulWidget { - const HelpSupportScreen({super.key}); - - @override - State createState() => _HelpSupportScreenState(); -} - -class _HelpSupportScreenState extends State { - final _faqs = const [ - FaqItem('How do I track?', - 'Track your delivery in real-time through the "Track" tab in the bottom navigation. Our EV fleet provides precision GPS updates every 10 seconds.'), - FaqItem('Price calculation?', - 'Fare = base fare + distance charge + any demand/weather surge. EV sustainability discounts are applied automatically at checkout.'), - FaqItem('Cancellation?', - 'You can cancel free of charge before a rider is assigned. After assignment, a small fee may apply to cover the EV trip.'), - ]; - - final List _messages = [ - const ChatMessage("Hi there! I'm Doormile Assistant. How can I help you today with your EV delivery?"), - const ChatMessage('I need to update my delivery address for order #DM-9921.', fromUser: true), - const ChatMessage('Sure! Our system shows your EV driver is still 10 mins away from pickup. I can update that for you now.'), - ]; - - final _input = TextEditingController(); - int? _openFaq = 0; - - void _send() { - final text = _input.text.trim(); - if (text.isEmpty) return; - setState(() { - _messages.add(ChatMessage(text, fromUser: true)); - _input.clear(); - }); - Future.delayed(const Duration(milliseconds: 700), () { - if (!mounted) return; - setState(() => _messages.add(const ChatMessage( - 'Thanks! A Doormile specialist will follow up shortly. Is there anything else?'))); - }); - } - - @override - void dispose() { - _input.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.surface, - appBar: AppBar( - leading: BackButton(color: AppColors.primary, onPressed: () => Navigator.pop(context)), - title: Text('Help & Support', - style: AppTheme.headlineMd.copyWith(color: AppColors.primary)), - actions: const [ - Padding( - padding: EdgeInsets.only(right: 16), - child: Icon(Icons.help_outline, color: AppColors.primary), - ), - ], - ), - body: ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), - children: [ - // Search - Container( - padding: const EdgeInsets.symmetric(horizontal: 14), - decoration: BoxDecoration( - color: AppColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(14), - boxShadow: AppTheme.cardShadow, - ), - child: Row( - children: [ - const Icon(Icons.search, color: AppColors.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: TextField( - cursorColor: AppColors.primary, - decoration: InputDecoration( - hintText: 'Search for help...', - hintStyle: AppTheme.bodyMd.copyWith(color: AppColors.outline), - border: InputBorder.none, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Text('Frequently Asked Questions', - style: AppTheme.headlineSm, overflow: TextOverflow.ellipsis), - ), - const SizedBox(width: 8), - Text('View all', - style: AppTheme.labelMd.copyWith(color: AppColors.primary)), - ], - ), - const SizedBox(height: 12), - ...List.generate(_faqs.length, (i) { - final open = _openFaq == i; - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: AppCard( - onTap: () => setState(() => _openFaq = open ? null : i), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text(_faqs[i].question, - style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)), - ), - Icon(open ? Icons.expand_less : Icons.expand_more, - color: AppColors.onSurfaceVariant), - ], - ), - AnimatedCrossFade( - duration: const Duration(milliseconds: 200), - crossFadeState: - open ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(_faqs[i].answer, - style: AppTheme.bodyMd - .copyWith(color: AppColors.onSurfaceVariant, height: 1.4)), - ), - secondChild: const SizedBox(width: double.infinity), - ), - ], - ), - ), - ); - }), - const SizedBox(height: 16), - Text('Recent Support', style: AppTheme.headlineSm), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppColors.surfaceContainer, - borderRadius: BorderRadius.circular(16), - ), - child: Column( - children: [ - ..._messages.map(_bubble), - const SizedBox(height: 8), - _composer(), - ], - ), - ), - const SizedBox(height: 16), - // Chat with us banner - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.primaryContainer, - borderRadius: BorderRadius.circular(16), - ), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12)), - child: const Icon(Icons.chat, color: Colors.white), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Chat with us', - style: AppTheme.labelMd.copyWith( - color: Colors.white, fontWeight: FontWeight.w700)), - Text('Get instant support from our team', - style: AppTheme.caption - .copyWith(color: Colors.white.withValues(alpha: 0.9))), - ], - ), - ), - const Icon(Icons.chevron_right, color: Colors.white), - ], - ), - ), - ], - ), - ); - } - - Widget _bubble(ChatMessage m) { - return Align( - alignment: m.fromUser ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.symmetric(vertical: 4), - padding: const EdgeInsets.all(12), - constraints: const BoxConstraints(maxWidth: 260), - decoration: BoxDecoration( - color: m.fromUser ? AppColors.primary : AppColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(14), - ), - child: Text(m.text, - style: AppTheme.bodyMd.copyWith( - color: m.fromUser ? Colors.white : AppColors.onSurface, height: 1.35)), - ), - ); - } - - Widget _composer() { - return Container( - padding: const EdgeInsets.only(left: 14, right: 6), - decoration: BoxDecoration( - color: AppColors.surfaceContainerLowest, - borderRadius: BorderRadius.circular(999), - ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _input, - cursorColor: AppColors.primary, - onSubmitted: (_) => _send(), - decoration: InputDecoration( - hintText: 'Type a message...', - hintStyle: AppTheme.bodyMd.copyWith(color: AppColors.outline), - border: InputBorder.none, - ), - ), - ), - GestureDetector( - onTap: _send, - child: Container( - width: 40, - height: 40, - decoration: const BoxDecoration( - color: AppColors.primary, shape: BoxShape.circle), - child: const Icon(Icons.send, color: Colors.white, size: 18), - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/tracking/screens/live_tracking_screen.dart b/lib/features/tracking/screens/live_tracking_screen.dart deleted file mode 100644 index 0068e28..0000000 --- a/lib/features/tracking/screens/live_tracking_screen.dart +++ /dev/null @@ -1,425 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:google_maps_flutter/google_maps_flutter.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; - -import 'package:doormile/shared/models/models.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/utils/snackbar_utils.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; - -class LiveTrackingScreen extends StatefulWidget { - final Order? order; - const LiveTrackingScreen({super.key, this.order}); - - @override - State createState() => _LiveTrackingScreenState(); -} - -class _LiveTrackingScreenState extends State { - static const _defaultCenter = LatLng(11.02, 76.95); - - WebSocketChannel? _channel; - GoogleMapController? _mapController; - - LatLng _milerPosition = _defaultCenter; - Set _markers = {}; - int _etaSeconds = 0; - String _milerName = 'Assigning Miler...'; - String _status = 'In transit'; - Timer? _etaTimer; - - @override - void initState() { - super.initState(); - _connectWebSocket(); - } - - void _connectWebSocket() { - final bookingId = widget.order?.id; - if (bookingId == null || bookingId.isEmpty) return; - try { - _channel = WebSocketChannel.connect( - Uri.parse('ws://api.doormile.com/ws/bookings/$bookingId/track'), - ); - _channel!.stream.listen( - _onMessage, - onError: (e) => debugPrint('WS error: $e'), - onDone: () => debugPrint('WS closed'), - cancelOnError: false, - ); - } catch (e) { - debugPrint('WS connect error: $e'); - } - } - - void _onMessage(dynamic raw) { - final Map data; - try { - data = jsonDecode(raw as String) as Map; - } catch (e) { - debugPrint('WS parse error: $e'); - return; - } - - final lat = (data['lat'] as num?)?.toDouble(); - final lon = (data['lon'] as num?)?.toDouble(); - final etaMinutes = (data['eta_minutes'] as num?)?.toInt(); - final milerName = data['miler_name'] as String?; - final status = data['status'] as String?; - bool positionChanged = false; - - if (!mounted) return; - - setState(() { - if (milerName != null && milerName.isNotEmpty) { - _milerName = milerName; - } - if (status != null && status.isNotEmpty) { - _status = status; - } - if (lat != null && lon != null && (lat != 0.0 || lon != 0.0)) { - _milerPosition = LatLng(lat, lon); - positionChanged = true; - _markers = { - Marker( - markerId: const MarkerId('miler'), - position: _milerPosition, - infoWindow: InfoWindow(title: _milerName), - ), - }; - } - if (etaMinutes != null) { - _etaSeconds = etaMinutes * 60; - } - }); - - if (etaMinutes != null) _restartEtaTimer(); - - if (positionChanged) { - _mapController?.animateCamera( - CameraUpdate.newLatLng(_milerPosition), - ); - } - } - - void _restartEtaTimer() { - _etaTimer?.cancel(); - if (_etaSeconds <= 0) return; - _etaTimer = Timer.periodic(const Duration(seconds: 1), (_) { - if (!mounted) return; - setState(() { - if (_etaSeconds > 0) _etaSeconds--; - }); - }); - } - - String get _etaLabel { - if (_etaSeconds > 0) { - return '${(_etaSeconds / 60).ceil()} min'; - } - return widget.order?.eta ?? '—'; - } - - @override - void dispose() { - _etaTimer?.cancel(); - _channel?.sink.close(); - _mapController?.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final id = widget.order?.id ?? 'DM-CHN-BLR-7741'; - - return Scaffold( - backgroundColor: AppColors.surface, - body: Stack( - children: [ - Positioned.fill( - child: GoogleMap( - initialCameraPosition: const CameraPosition( - target: _defaultCenter, - zoom: 14, - ), - markers: _markers, - onMapCreated: (controller) => _mapController = controller, - myLocationButtonEnabled: false, - zoomControlsEnabled: false, - ), - ), - SafeArea( - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - _circleBtn(Icons.arrow_back, () => Navigator.pop(context)), - const SizedBox(width: 10), - Expanded( - child: Text('Live Tracking', - style: AppTheme.headlineSm.copyWith(color: AppColors.primary), - overflow: TextOverflow.ellipsis), - ), - const SizedBox(width: 8), - StatusChip( - label: 'EV Priority', - icon: Icons.eco, - color: AppColors.tertiary, - bg: Colors.white, - ), - ], - ), - ), - ), - Align( - alignment: Alignment.bottomCenter, - child: Container( - width: double.infinity, - decoration: const BoxDecoration( - color: AppColors.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: [ - BoxShadow(color: Color(0x1A000000), blurRadius: 24, offset: Offset(0, -6)), - ], - ), - child: SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('PARCEL ID', - style: AppTheme.caption.copyWith(letterSpacing: 1)), - Text(id, - style: AppTheme.headlineSm - .copyWith(fontWeight: FontWeight.w700)), - ], - ), - ), - StatusChip( - label: _status, - color: AppColors.tertiary, - bg: AppColors.tertiaryFixed.withValues(alpha: 0.5), - ), - ], - ), - const SizedBox(height: 4), - Text('ETA $_etaLabel', - style: AppTheme.labelMd.copyWith(color: AppColors.primary)), - const SizedBox(height: 16), - const _MileStepper(activeIndex: 2), - const SizedBox(height: 16), - _riderCard(context), - const SizedBox(height: 12), - const EcoBanner( - text: 'Eco-Friendly Journey · saving 1.2kg of CO₂ on this delivery.'), - ], - ), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _circleBtn(IconData icon, VoidCallback onTap) { - return Material( - color: Colors.white, - shape: const CircleBorder(), - elevation: 2, - child: InkWell( - onTap: onTap, - customBorder: const CircleBorder(), - child: SizedBox( - width: 40, height: 40, child: Icon(icon, color: AppColors.primary, size: 22)), - ), - ); - } - - Widget _riderCard(BuildContext context) { - return AppCard( - color: AppColors.surfaceContainerLow, - child: Column( - children: [ - Row( - children: [ - const CircleAvatar( - radius: 24, - backgroundColor: AppColors.primaryFixed, - child: Icon(Icons.person, color: AppColors.primary, size: 28), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(_milerName, - style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)), - Row( - children: [ - StatusChip( - label: 'EV', - icon: Icons.bolt, - color: AppColors.tertiary, - bg: AppColors.tertiaryFixed.withValues(alpha: 0.5)), - const SizedBox(width: 6), - Flexible( - child: Text('KA-01-EV-4412', - style: AppTheme.caption, overflow: TextOverflow.ellipsis), - ), - ], - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text('★ 4.9', - style: AppTheme.labelMd.copyWith(color: AppColors.warning)), - Text('4821 trips', style: AppTheme.caption), - ], - ), - ], - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () async { - final uri = Uri(scheme: 'tel', path: '+919876543210'); - try { - await launchUrl(uri); - } catch (e) { - if (context.mounted) _toast(context, 'Could not launch dialer.'); - } - }, - icon: const Icon(Icons.call, size: 18), - label: const Text('Call Rider'), - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.primary, - side: const BorderSide(color: AppColors.primary), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - onPressed: () => Navigator.pushNamed( - context, - '/chat', - arguments: { - 'milerName': _milerName, - 'bookingId': widget.order?.id ?? '', - }, - ), - icon: const Icon(Icons.chat_bubble_outline, size: 18), - label: const Text('Chat'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - elevation: 0, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - ), - ), - ), - ], - ), - ], - ), - ); - } - - void _toast(BuildContext context, String msg) { - SnackbarUtils.showError(context, msg); - } -} - -class _MileStepper extends StatelessWidget { - final int activeIndex; - const _MileStepper({required this.activeIndex}); - - static const _stops = [ - ('Booked', AppColors.primary), - ('First Mile', AppColors.firstMile), - ('Mid Mile', AppColors.midMile), - ('Last Mile', AppColors.lastMile), - ]; - - @override - Widget build(BuildContext context) { - return Row( - children: List.generate(_stops.length, (i) { - final done = i <= activeIndex; - final stop = _stops[i]; - return Expanded( - child: Column( - children: [ - Row( - children: [ - Expanded( - child: Container( - height: 3, - color: i == 0 - ? Colors.transparent - : (i <= activeIndex ? stop.$2 : AppColors.surfaceContainerHigh), - ), - ), - Container( - width: 14, - height: 14, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: done ? stop.$2 : AppColors.surfaceContainerHigh, - border: Border.all( - color: done ? stop.$2 : AppColors.outlineVariant, width: 2), - ), - child: done - ? const Icon(Icons.check, size: 8, color: Colors.white) - : null, - ), - Expanded( - child: Container( - height: 3, - color: i == _stops.length - 1 - ? Colors.transparent - : (i < activeIndex - ? _stops[i + 1].$2 - : AppColors.surfaceContainerHigh), - ), - ), - ], - ), - const SizedBox(height: 6), - Text(stop.$1, - style: AppTheme.caption.copyWith( - color: done ? stop.$2 : AppColors.onSurfaceVariant, - fontWeight: i == activeIndex ? FontWeight.w700 : FontWeight.w500, - )), - ], - ), - ); - }), - ); - } -} diff --git a/lib/features/tracking/screens/my_orders_screen.dart b/lib/features/tracking/screens/my_orders_screen.dart deleted file mode 100644 index 30bb116..0000000 --- a/lib/features/tracking/screens/my_orders_screen.dart +++ /dev/null @@ -1,278 +0,0 @@ -import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import 'package:doormile/main.dart'; -import 'package:doormile/shared/models/models.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; - -class MyOrdersScreen extends StatefulWidget { - /// When embedded in the bottom-nav shell we hide the back button. - final bool embedded; - const MyOrdersScreen({super.key, this.embedded = false}); - - @override - State createState() => _MyOrdersScreenState(); -} - -class _MyOrdersScreenState extends State { - final _filters = const ['All', 'Active', 'Delivered', 'Cancelled']; - int _filter = 0; - Timer? _pollingTimer; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().fetchOrders(); - }); - - // Start polling every 10 seconds for live status updates - _pollingTimer = Timer.periodic(const Duration(seconds: 10), (_) { - if (mounted) { - context.read().fetchOrders(); - } - }); - } - - @override - void dispose() { - _pollingTimer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final state = context.watch(); - final orders = state.orders; - final filtered = switch (_filter) { - 1 => orders.where((o) => o.status == OrderStatus.active).toList(), - 2 => orders.where((o) => o.status == OrderStatus.delivered).toList(), - 3 => orders.where((o) => o.status == OrderStatus.cancelled).toList(), - _ => orders, - }; - - return AppBackground( - child: Column( - children: [ - ImmersiveHeader( - title: 'My Parcels', - subtitle: 'Track all your deliveries', - icon: Icons.inventory_2_rounded, - showBack: !widget.embedded, - ), - const SizedBox(height: 16), - SizedBox( - height: 44, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: _filters.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (context, i) { - final active = i == _filter; - return GestureDetector( - onTap: () => setState(() => _filter = i), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 18), - alignment: Alignment.center, - decoration: BoxDecoration( - color: active ? AppColors.primary : AppColors.surfaceContainer, - borderRadius: BorderRadius.circular(999), - ), - child: Text(_filters[i], - style: AppTheme.labelMd.copyWith( - color: active ? Colors.white : AppColors.onSurfaceVariant)), - ), - ); - }, - ), - ), - Expanded( - child: state.isLoadingOrders - ? const Center(child: CircularProgressIndicator(color: AppColors.primary)) - : filtered.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 100, - height: 100, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - colors: [AppColors.primary.withValues(alpha: 0.1), AppColors.primary.withValues(alpha: 0.25)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.15), - blurRadius: 24, - spreadRadius: 4, - offset: const Offset(0, 10), - ), - ], - ), - child: Center( - child: Container( - width: 68, - height: 68, - decoration: const BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.inventory_2_rounded, - size: 34, - color: AppColors.primary, - ), - ), - ), - ), - const SizedBox(height: 24), - Text('No parcels found', - style: AppTheme.headlineSm.copyWith(color: AppColors.onSurface, fontWeight: FontWeight.w700)), - const SizedBox(height: 8), - Text('You have no active bookings in this category.', - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)), - const SizedBox(height: 48), // Padding to lift it slightly above true center - ], - ), - ) - : ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), - itemCount: filtered.length, - separatorBuilder: (_, __) => const SizedBox(height: 14), - itemBuilder: (context, i) => _OrderCard(order: filtered[i]), - ), - ), - ], - ), - ); - } -} - -class _OrderCard extends StatelessWidget { - final Order order; - const _OrderCard({required this.order}); - - @override - Widget build(BuildContext context) { - final active = order.status == OrderStatus.active; - return AppCard( - onTap: () => Navigator.pushNamed( - context, - active ? Routes.liveTracking : Routes.shipmentJourney, - arguments: order, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Order ID', style: AppTheme.caption), - Text('#${order.id}', - style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)), - ], - ), - ), - StatusChip( - label: order.status.label, color: order.status.color, bg: order.status.bg), - ], - ), - const SizedBox(height: 14), - // Route mini timeline - _routeRow(Icons.radio_button_unchecked, order.fromLabel, order.fromSub, - AppColors.primary, dashed: true), - _routeRow(Icons.location_on, order.toLabel, order.toSub, AppColors.onSurfaceVariant), - if (active) ...[ - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Text('Journey Progress', - style: AppTheme.caption.copyWith(color: AppColors.primary), - overflow: TextOverflow.ellipsis), - ), - const SizedBox(width: 8), - Text('${(order.progress * 3).toStringAsFixed(1)} / 3.0 Miles', - style: AppTheme.caption), - ], - ), - const SizedBox(height: 6), - JourneyProgressBar(value: order.progress), - ], - const SizedBox(height: 12), - Row( - children: [ - if (order.ecoRoute) ...[ - const Icon(Icons.eco, size: 14, color: AppColors.tertiary), - const SizedBox(width: 4), - Text('CO₂ saved: ${order.co2Saved} (EV)', - style: AppTheme.caption.copyWith(color: AppColors.tertiary)), - ], - ], - ), - const Divider(height: 22, color: AppColors.surfaceContainer), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(order.date, - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)), - Text('₹${order.amount.toStringAsFixed(2)}', - style: AppTheme.headlineSm.copyWith(fontWeight: FontWeight.w700)), - ], - ), - ], - ), - ); - } - - Widget _routeRow(IconData icon, String title, String sub, Color color, - {bool dashed = false}) { - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - children: [ - Icon(icon, size: 18, color: color), - if (dashed) - Expanded( - child: Container( - width: 1.5, - margin: const EdgeInsets.symmetric(vertical: 2), - color: AppColors.outlineVariant, - ), - ), - ], - ), - const SizedBox(width: 12), - Expanded( - child: Padding( - padding: EdgeInsets.only(bottom: dashed ? 10 : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)), - Text(sub, style: AppTheme.caption), - ], - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/features/tracking/screens/shipment_journey_screen.dart b/lib/features/tracking/screens/shipment_journey_screen.dart deleted file mode 100644 index d22f465..0000000 --- a/lib/features/tracking/screens/shipment_journey_screen.dart +++ /dev/null @@ -1,319 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:doormile/shared/models/models.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/widgets/common.dart'; -import 'package:doormile/core/widgets/logo.dart'; - -class ShipmentJourneyScreen extends StatelessWidget { - final Order? order; - const ShipmentJourneyScreen({super.key, this.order}); - - @override - Widget build(BuildContext context) { - final steps = const [ - JourneyStep( - title: 'First Mile', - subtitle: 'Picked up from Guindy Hub, Chennai\nAssigned to EV-Fleet #TN01-EV-4421', - time: '7 Jun, 10:45 AM', - icon: Icons.inventory_2, - color: AppColors.firstMile, - done: true, - ), - JourneyStep( - title: 'Mid Mile · IN TRANSIT', - subtitle: - 'Departed Chennai Regional Hub\nEnroute to Nelamangala Distribution Center', - time: 'Active Now', - icon: Icons.local_shipping, - color: AppColors.midMile, - active: true, - note: - 'AI REROUTE · Avoided AH45 congestion near Krishnagiri. Diverted to optimized arterial route.', - ), - JourneyStep( - title: 'Last Mile', - subtitle: 'Out for Delivery\nTo: Indiranagar, Bengaluru Hub', - time: 'Expected 9 Jun', - icon: Icons.home_filled, - color: AppColors.lastMile, - ), - ]; - - final logs = const [ - ActivityLog('Security Check Complete', 'Chennai Regional Center · 08 Jun 14:12', - Icons.verified_user_outlined), - ActivityLog('Sorted for Destination', 'Chennai Regional Center · 08 Jun 11:30', - Icons.alt_route), - ActivityLog('Arrived at Sorting Hub', 'Chennai Hub A · 07 Jun 18:45', - Icons.warehouse_outlined), - ActivityLog('Shipment Created', 'Electronic City Office · 07 Jun 09:15', - Icons.add_box_outlined), - ]; - - return Scaffold( - backgroundColor: AppColors.surface, - appBar: AppBar( - leading: BackButton(color: AppColors.primary, onPressed: () => Navigator.pop(context)), - title: const DoormileWordmark(markSize: 32, fontSize: 22), - actions: const [ - Padding( - padding: EdgeInsets.only(right: 16), - child: Icon(Icons.location_on_outlined, color: AppColors.primary), - ), - ], - ), - body: ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), - children: [ - // Summary card - AppCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Shipment Contents', style: AppTheme.caption), - Text('Documents & electronics · 3.2 kg', - style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)), - ], - ), - ), - StatusChip( - label: 'EV EXPRESS', - icon: Icons.eco, - color: AppColors.tertiary, - bg: AppColors.tertiaryFixed.withValues(alpha: 0.6), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - _city('MAA', 'Chennai'), - const Expanded( - child: Icon(Icons.bolt, color: AppColors.primary), - ), - _city('BLR', 'Bengaluru', end: true), - ], - ), - const Divider(height: 28, color: AppColors.surfaceContainer), - Row( - children: [ - const Icon(Icons.schedule, size: 18, color: AppColors.onSurfaceVariant), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Estimated Arrival', style: AppTheme.caption), - Text('9 Jun 2:30 PM', - style: AppTheme.labelMd.copyWith(fontWeight: FontWeight.w700)), - ], - ), - ), - PrimaryButton( - label: 'Track Live', - height: 44, - onPressed: () => Navigator.pushNamed(context, '/tracking/live', - arguments: order), - ).constrainedTo(120), - ], - ), - ], - ), - ), - const SizedBox(height: 12), - // AI insight banner - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppColors.tertiaryFixed.withValues(alpha: 0.35), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.tertiary.withValues(alpha: 0.15)), - ), - child: Row( - children: [ - const Icon(Icons.psychology_alt, color: AppColors.tertiary), - const SizedBox(width: 12), - Expanded( - child: Text('MileTruth AI Insight · Rerouted via Hosur bypass — ETA protected despite traffic.', - style: AppTheme.bodyMd - .copyWith(color: AppColors.tertiary, height: 1.35)), - ), - ], - ), - ), - const SizedBox(height: 24), - Text('Journey Timeline', style: AppTheme.headlineMd), - const SizedBox(height: 12), - ...List.generate(steps.length, (i) => - _TimelineTile(step: steps[i], isLast: i == steps.length - 1)), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Activity Log', style: AppTheme.headlineMd), - Text('All Updates', - style: AppTheme.labelMd.copyWith(color: AppColors.primary)), - ], - ), - const SizedBox(height: 12), - AppCard( - child: Column( - children: [ - for (int i = 0; i < logs.length; i++) ...[ - Row( - children: [ - IconBadge( - icon: logs[i].icon, - bg: AppColors.surfaceContainer, - color: AppColors.onSurfaceVariant, - size: 38, - iconSize: 18, - circle: true, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(logs[i].title, - style: AppTheme.labelMd - .copyWith(fontWeight: FontWeight.w700)), - Text(logs[i].subtitle, style: AppTheme.caption), - ], - ), - ), - ], - ), - if (i < logs.length - 1) - const Divider(height: 22, color: AppColors.surfaceContainer), - ], - ], - ), - ), - ], - ), - ); - } - - Widget _city(String code, String name, {bool end = false}) { - return Column( - crossAxisAlignment: end ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - Text(code, style: AppTheme.headlineMd.copyWith(color: AppColors.primary)), - Text(name, style: AppTheme.caption), - ], - ); - } -} - -class _TimelineTile extends StatelessWidget { - final JourneyStep step; - final bool isLast; - const _TimelineTile({required this.step, required this.isLast}); - - @override - Widget build(BuildContext context) { - return IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: step.done || step.active - ? step.color.withValues(alpha: 0.15) - : AppColors.surfaceContainer, - shape: BoxShape.circle, - border: step.active - ? Border.all(color: step.color, width: 2) - : null, - ), - child: Icon(step.icon, - color: step.done || step.active ? step.color : AppColors.outline, - size: 20), - ), - if (!isLast) - Expanded( - child: Container( - width: 2.5, - color: step.done ? step.color : AppColors.surfaceContainerHigh, - ), - ), - ], - ), - const SizedBox(width: 14), - Expanded( - child: Padding( - padding: const EdgeInsets.only(bottom: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text(step.title, - style: AppTheme.labelMd.copyWith( - color: step.color, fontWeight: FontWeight.w700)), - ), - Text(step.time, - style: AppTheme.caption.copyWith( - color: step.active - ? AppColors.tertiary - : AppColors.onSurfaceVariant, - fontWeight: - step.active ? FontWeight.w700 : FontWeight.w400)), - ], - ), - const SizedBox(height: 4), - Text(step.subtitle, - style: AppTheme.bodyMd.copyWith(color: AppColors.onSurfaceVariant)), - if (step.note != null) ...[ - const SizedBox(height: 10), - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.warning.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: AppColors.warning.withValues(alpha: 0.25)), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Icon(Icons.warning_amber_rounded, - size: 16, color: AppColors.warning), - const SizedBox(width: 8), - Expanded( - child: Text(step.note!, - style: AppTheme.caption.copyWith( - color: AppColors.warning, height: 1.4)), - ), - ], - ), - ), - ], - ], - ), - ), - ), - ], - ), - ); - } -} - -extension on Widget { - Widget constrainedTo(double width) => - ConstrainedBox(constraints: BoxConstraints(maxWidth: width), child: this); -} diff --git a/lib/main.dart b/lib/main.dart index 32cf07a..a0107d1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,186 +1,180 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:doormile/shared/state/app_state.dart'; -import 'package:doormile/core/theme/app_theme.dart'; -import 'package:doormile/core/theme/app_colors.dart'; -import 'package:doormile/features/auth/screens/onboarding_screen.dart'; -import 'package:doormile/features/auth/screens/login_screen.dart'; -import 'package:doormile/features/auth/screens/signup_screen.dart'; -import 'package:doormile/features/auth/screens/otp_screen.dart'; -import 'package:doormile/features/auth/screens/pin_screen.dart'; -import 'package:doormile/features/dashboard/screens/main_shell.dart'; -import 'package:doormile/features/booking/screens/booking_pickup_screen.dart'; -import 'package:doormile/features/booking/screens/parcel_details_screen.dart'; -import 'package:doormile/features/booking/screens/service_price_screen.dart'; -import 'package:doormile/features/booking/screens/booking_preview_screen.dart'; -import 'package:doormile/features/booking/screens/booking_success_screen.dart'; -import 'package:doormile/features/tracking/screens/my_orders_screen.dart'; -import 'package:doormile/features/tracking/screens/live_tracking_screen.dart'; -import 'package:doormile/features/tracking/screens/shipment_journey_screen.dart'; -import 'package:doormile/features/support/screens/help_support_screen.dart'; -import 'package:doormile/features/dashboard/screens/saved_addresses_screen.dart'; -import 'package:doormile/features/support/screens/chat_screen.dart'; -import 'package:doormile/shared/models/models.dart'; +import 'state/app_scope.dart'; +import 'state/app_state.dart'; +import 'ui/screens/auth/login_screen.dart'; +import 'ui/screens/shell_screen.dart'; +import 'ui/tokens.dart'; -void main() async { +void main() { WidgetsFlutterBinding.ensureInitialized(); - try { - await Firebase.initializeApp(); - } catch (e) { - debugPrint('Firebase initialization error: $e'); + + // Draw behind the system bars — expected on Android 15+ and the reason the + // screens manage their own SafeArea insets. + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + + runApp(const DoormileApp()); +} + +/// Doormile — customer app. +/// +/// Book pickup → Track → Delivered. +/// +/// `lib/data/doormile_api.dart` is the service surface, and it has exactly one +/// implementation: the live customer API. Nothing on screen is invented. +class DoormileApp extends StatefulWidget { + const DoormileApp({super.key, this.initialState}); + + /// Injectable for tests. + final AppState? initialState; + + @override + State createState() => _DoormileAppState(); +} + +class _DoormileAppState extends State { + late final AppState _state = widget.initialState ?? AppState(); + + @override + void initState() { + super.initState(); + // A returning customer's session outlives the process: the refresh token is + // good for 60 days. Look for it before deciding which screen to open. + unawaited(_state.restoreSession()); } - final prefs = await SharedPreferences.getInstance(); - final token = prefs.getString('auth_token'); - final initialRoute = (token != null && token.isNotEmpty) ? Routes.home : Routes.onboarding; - - SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( - statusBarColor: Colors.transparent, - statusBarIconBrightness: Brightness.dark, - )); - runApp(DoormileApp(initialRoute: initialRoute)); -} - -class Routes { - static const splash = '/'; - static const onboarding = '/onboarding'; - static const login = '/login'; - static const signup = '/signup'; - static const otp = '/otp'; - static const enterPin = '/enter-pin'; - static const setPin = '/set-pin'; - static const resetPin = '/reset-pin'; - static const home = '/home'; - static const bookingPickup = '/booking/pickup'; - static const parcelDetails = '/booking/details'; - static const servicePrice = '/booking/service'; - static const bookingPreview = '/booking/preview'; - static const bookingSuccess = '/booking/success'; - static const myOrders = '/orders'; - static const liveTracking = '/tracking/live'; - static const shipmentJourney = '/tracking/journey'; - static const help = '/help'; - static const addresses = '/addresses'; - static const chat = '/chat'; -} - -class DoormileApp extends StatelessWidget { - final String initialRoute; - const DoormileApp({super.key, required this.initialRoute}); + @override + void dispose() { + _state.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (_) => AppState(), - child: _buildApp(), - ); - } - - Widget _buildApp() { - return MaterialApp( - title: 'Doormile', - debugShowCheckedModeBanner: false, - theme: AppTheme.light, - initialRoute: initialRoute, - onGenerateRoute: (settings) { - Widget page; - switch (settings.name) { - case Routes.onboarding: - page = const OnboardingScreen(); - break; - case Routes.login: - page = const LoginScreen(); - break; - case Routes.signup: - page = const SignupScreen(); - break; - case Routes.otp: - page = const OtpScreen(); - break; - case Routes.enterPin: - page = const PinScreen(mode: PinMode.enter); - break; - case Routes.setPin: - page = const PinScreen(mode: PinMode.set); - break; - case Routes.resetPin: - page = const PinScreen(mode: PinMode.reset); - break; - case Routes.home: - page = const MainShell(); - break; - case Routes.bookingPickup: - page = const BookingPickupScreen(); - break; - case Routes.parcelDetails: - page = const ParcelDetailsScreen(); - break; - case Routes.servicePrice: - page = const ServicePriceScreen(); - break; - case Routes.bookingPreview: - page = const BookingPreviewScreen(); - break; - case Routes.bookingSuccess: - page = const BookingSuccessScreen(); - break; - case Routes.myOrders: - page = const MyOrdersScreen(); - break; - case Routes.liveTracking: - page = LiveTrackingScreen(order: settings.arguments as Order?); - break; - case Routes.shipmentJourney: - page = ShipmentJourneyScreen(order: settings.arguments as Order?); - break; - case Routes.help: - page = const HelpSupportScreen(); - break; - case Routes.addresses: - page = const SavedAddressesScreen(); - break; - case Routes.chat: - final chatArgs = settings.arguments as Map? ?? {}; - page = Builder( - builder: (context) { - final appState = Provider.of(context, listen: false); - return ChatScreen( - riderName: chatArgs['milerName'] ?? 'Rider', - bookingId: chatArgs['bookingId'] ?? '', - authToken: appState.authToken, - ); - }, - ); - break; - default: - page = const MainShell(); - } - return PageRouteBuilder( - settings: settings, - pageBuilder: (_, __, ___) => page, - transitionsBuilder: (_, animation, __, child) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween(begin: const Offset(0, 0.02), end: Offset.zero) - .animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)), - child: child, - ), - ); - }, - transitionDuration: const Duration(milliseconds: 280), - ); - }, + return AppScope( + state: _state, + child: MaterialApp( + title: 'Doormile', + debugShowCheckedModeBanner: false, + theme: buildDoormileTheme(), + home: const _Launch(), + // No text-scale clamp: components adapt instead. Rows grow, important + // text wraps, and ellipsis is reserved for secondary information. + builder: (context, child) { + return AnnotatedRegion( + value: const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarIconBrightness: Brightness.dark, + ), + child: child ?? const SizedBox.shrink(), + ); + }, + ), ); } } -/// Selection colour helper used by text fields. -final selectionTheme = const TextSelectionThemeData( - selectionColor: AppColors.primaryFixed, - cursorColor: AppColors.primary, -); +ThemeData buildDoormileTheme() { + const scheme = ColorScheme.light( + primary: DmColors.brand, + onPrimary: Colors.white, + secondary: DmColors.brand, + onSecondary: Colors.white, + surface: DmColors.surface, + onSurface: DmColors.ink, + error: DmColors.danger, + ); + + return ThemeData( + useMaterial3: true, + fontFamily: 'Manrope', + colorScheme: scheme, + scaffoldBackgroundColor: DmColors.bg, + splashFactory: InkSparkle.splashFactory, + highlightColor: const Color(0x0A17110F), + textSelectionTheme: const TextSelectionThemeData( + cursorColor: DmColors.brand, + selectionColor: Color(0x338F0F06), + selectionHandleColor: DmColors.brand, + ), + textTheme: const TextTheme().apply( + bodyColor: DmColors.ink, + displayColor: DmColors.ink, + ), + dividerTheme: const DividerThemeData( + color: DmColors.hairline, + thickness: 1, + space: 1, + ), + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: _DmPageTransitions(), + TargetPlatform.iOS: _DmPageTransitions(), + TargetPlatform.macOS: _DmPageTransitions(), + TargetPlatform.windows: _DmPageTransitions(), + TargetPlatform.linux: _DmPageTransitions(), + }, + ), + ); +} + +/// Forward navigation slides in from the right and fades, matching the +/// prototype's screen transition. +class _DmPageTransitions extends PageTransitionsBuilder { + const _DmPageTransitions(); + + @override + Widget buildTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + return FadeTransition( + opacity: curved, + child: SlideTransition( + position: Tween( + begin: const Offset(0.06, 0), + end: Offset.zero, + ).animate(curved), + child: child, + ), + ); + } +} + +/// What the app opens on. +/// +/// Three states, and the middle one is the reason this widget exists: until the +/// stored session has been looked for we do not yet know whether this customer +/// is signed in, and showing them a login form that vanishes a moment later is +/// worse than showing them nothing. The Android 12 splash is still up for most +/// of it. +class _Launch extends StatelessWidget { + const _Launch(); + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + if (app.restoringSession) { + return const Scaffold(backgroundColor: DmColors.canvas, body: SizedBox()); + } + return app.isSignedIn ? const ShellScreen() : const LoginScreen(); + } +} diff --git a/lib/shared/models/models.dart b/lib/shared/models/models.dart deleted file mode 100644 index 7c501c5..0000000 --- a/lib/shared/models/models.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:doormile/core/theme/app_colors.dart'; - -/// A saved or recent address entry. -class AddressEntry { - final String label; // e.g. "Home", "Work" - final String line; // full address line - final IconData icon; - final Color iconBg; - final Color iconColor; - final double latitude; - final double longitude; - final String pincode; - - const AddressEntry({ - required this.label, - required this.line, - required this.icon, - this.iconBg = AppColors.surfaceContainer, - this.iconColor = AppColors.onSurfaceVariant, - this.latitude = 0.0, - this.longitude = 0.0, - this.pincode = '', - }); -} - -enum OrderStatus { active, delivered, cancelled } - -extension OrderStatusX on OrderStatus { - String get label => switch (this) { - OrderStatus.active => 'In Transit', - OrderStatus.delivered => 'Delivered', - OrderStatus.cancelled => 'Cancelled', - }; - - Color get color => switch (this) { - OrderStatus.active => AppColors.tertiary, - OrderStatus.delivered => AppColors.success, - OrderStatus.cancelled => AppColors.error, - }; - - Color get bg => switch (this) { - OrderStatus.active => AppColors.tertiaryFixed, - OrderStatus.delivered => const Color(0xFFE3F6EC), - OrderStatus.cancelled => AppColors.errorContainer, - }; -} - -/// One stop in a shipment journey timeline. -class JourneyStep { - final String title; - final String subtitle; - final String time; - final IconData icon; - final Color color; - final bool done; - final bool active; - final String? note; - - const JourneyStep({ - required this.title, - required this.subtitle, - required this.time, - required this.icon, - required this.color, - this.done = false, - this.active = false, - this.note, - }); -} - -class ActivityLog { - final String title; - final String subtitle; - final IconData icon; - const ActivityLog(this.title, this.subtitle, this.icon); -} - -/// A parcel order. -class Order { - final String id; - final String fromLabel; - final String fromSub; - final String toLabel; - final String toSub; - final String date; - final double amount; - final OrderStatus status; - final String parcelType; - final String weight; - final String size; - final double progress; // 0..1 - final String co2Saved; - final String eta; - final bool ecoRoute; - - const Order({ - required this.id, - required this.fromLabel, - required this.fromSub, - required this.toLabel, - required this.toSub, - required this.date, - required this.amount, - required this.status, - this.parcelType = 'Documents', - this.weight = '0–1 kg', - this.size = 'Small', - this.progress = 0.0, - this.co2Saved = '0.8kg', - this.eta = '—', - this.ecoRoute = true, - }); -} - - -class FaqItem { - final String question; - final String answer; - const FaqItem(this.question, this.answer); -} - -class ChatMessage { - final String text; - final bool fromUser; - const ChatMessage(this.text, {this.fromUser = false}); -} diff --git a/lib/shared/state/app_state.dart b/lib/shared/state/app_state.dart deleted file mode 100644 index 69b1bcd..0000000 --- a/lib/shared/state/app_state.dart +++ /dev/null @@ -1,672 +0,0 @@ -import 'dart:convert'; -import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:http/http.dart' as http; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:geolocator/geolocator.dart'; - -import 'package:doormile/core/constants/api_constants.dart'; - -import 'package:doormile/shared/models/models.dart'; -import 'package:doormile/core/theme/app_colors.dart'; - -/// Holds the in-progress booking the user is composing across the flow screens. -class BookingDraft { - String pickupAddress = ''; - String pickupPincode = ''; - double pickupLatitude = 0.0; - double pickupLongitude = 0.0; - String contactName = ''; - String contactMobile = ''; - String notes = ''; - - String destination = ''; - String service = ''; - String parcelType = ''; - String weight = '2 kg'; - String size = 'Small'; - String pickupDay = 'Today'; - String pickupSlot = '10:00 AM - 12:00 PM'; - bool fragile = false; - String dropAddress = ''; - String dropPincode = ''; - String paymentMethod = 'Cash'; - bool needsInsurance = false; - double declaredValue = 0.0; - - double minPrice = 45.0; - double maxPrice = 86.0; - - void reset() { - pickupAddress = ''; - pickupPincode = ''; - pickupLatitude = 0.0; - pickupLongitude = 0.0; - contactName = ''; - contactMobile = ''; - notes = ''; - destination = ''; - service = ''; - parcelType = ''; - weight = '2 kg'; - size = 'Small'; - pickupDay = 'Today'; - pickupSlot = '10:00 AM - 12:00 PM'; - fragile = false; - dropAddress = ''; - dropPincode = ''; - paymentMethod = 'Cash'; - needsInsurance = false; - declaredValue = 0.0; - minPrice = 45.0; - maxPrice = 86.0; - } -} - -/// Global, in-memory app state — auth, wallet, orders, and the booking draft. -class AppState extends ChangeNotifier { - // ---- Auth ---- - bool isLoggedIn = false; - String phone = ''; - String? verificationId; - String userName = ''; // Updated via profile/login - String firstName = ''; - - String? pendingFirstName; - String? pendingEmail; - String? authToken; - bool isResettingPin = false; - String? _deviceToken; - - AppState() { - _loadSession(); - } - - Future _loadSession() async { - final prefs = await SharedPreferences.getInstance(); - authToken = prefs.getString('auth_token'); - if (authToken != null && authToken!.isNotEmpty) { - isLoggedIn = true; - firstName = prefs.getString('first_name') ?? 'User'; - userName = prefs.getString('user_name') ?? 'User'; - phone = prefs.getString('phone') ?? ''; - - // Wipe mock data because this is a real authenticated session - orders.clear(); - savedAddresses.clear(); - - notifyListeners(); - } - } - - Future _saveSession() async { - final prefs = await SharedPreferences.getInstance(); - if (authToken != null) await prefs.setString('auth_token', authToken!); - await prefs.setString('first_name', firstName); - await prefs.setString('user_name', userName); - await prefs.setString('phone', phone); - } - - Future _fetchDeviceToken() async { - try { - if (kIsWeb) return null; - await FirebaseMessaging.instance.requestPermission(alert: true, badge: true, sound: true); - _deviceToken = await FirebaseMessaging.instance.getToken(); - return _deviceToken; - } catch (e) { - debugPrint('FCM token error: $e'); - return null; - } - } - - void setPhone(String value) { - phone = value; - notifyListeners(); - } - - /// Sends a 6-digit OTP to [phoneNumber] via Firebase Phone Auth. - Future sendOtp(String phoneNumber, {String? firstName, String? email, bool resetPin = false, required Function onSuccess, required Function(String) onError}) async { - setPhone(phoneNumber); - pendingFirstName = firstName ?? pendingFirstName; - pendingEmail = email ?? pendingEmail; - isResettingPin = resetPin; - - final rawPhone = phoneNumber.replaceAll(' ', ''); - if (rawPhone.length != 10) { - onError('A valid mobile number is required for verification.'); - return; - } - final fullPhone = '+91$rawPhone'; - - if (kIsWeb) { - debugPrint('[OTP Service] Web build: bypassing Firebase OTP (mocking success)'); - Future.delayed(const Duration(seconds: 1), () { - verificationId = 'mock_verification_id_$fullPhone'; - onSuccess(); - }); - return; - } - - try { - debugPrint('[OTP Service] Initiating Firebase phone verification for $fullPhone'); - await FirebaseAuth.instance.verifyPhoneNumber( - phoneNumber: fullPhone, - verificationCompleted: (PhoneAuthCredential credential) async { - debugPrint('[OTP Service] Verification completed automatically'); - try { - await FirebaseAuth.instance.signInWithCredential(credential); - } catch (e) { - debugPrint('[OTP Service] Error signing in: $e'); - } - }, - verificationFailed: (FirebaseAuthException e) { - debugPrint('[OTP Service] Verification failed: ${e.code} - ${e.message}'); - onError(e.message ?? 'Failed to send verification code.'); - }, - codeSent: (String vId, int? resendToken) { - debugPrint('[OTP Service] Code sent. Verification ID: $vId'); - verificationId = vId; - onSuccess(); - }, - codeAutoRetrievalTimeout: (String vId) { - debugPrint('[OTP Service] Auto retrieval timeout. Verification ID: $vId'); - verificationId = vId; - }, - ); - } catch (e) { - debugPrint('[OTP Service] Exception during OTP request: $e'); - onError('Error sending verification code: $e'); - } - } - - /// Debug-only tester shortcut: if `smsDefaultProvider`/`smsPassKey` are set - /// in SharedPreferences, that fixed passkey is accepted instead of the real - /// OTP. `kDebugMode` means this branch is compiled out of release builds - /// entirely, so it can never ship as a bypass. - Future _checkDebugBypass(String smsCode) async { - if (!kDebugMode) return false; - final prefs = await SharedPreferences.getInstance(); - final provider = prefs.getInt('smsDefaultProvider') ?? 0; - final passKey = prefs.getInt('smsPassKey'); - return provider == 1 && passKey != null && smsCode == passKey.toString().padLeft(6, '0'); - } - - Future verifyOtp(String smsCode, {required Function onSuccess, required Function(String) onError}) async { - if (await _checkDebugBypass(smsCode)) { - onSuccess(); - return; - } - - if (verificationId == null) { - onError('Verification ID is missing. Please resend OTP.'); - return; - } - - if (kIsWeb) { - debugPrint('[OTP Service] Web build: bypassing Firebase OTP verification (mocking success)'); - if (smsCode.length == 6) { - onSuccess(); - } else { - onError('Invalid mock OTP code (must be 6 digits).'); - } - return; - } - - try { - final credential = PhoneAuthProvider.credential( - verificationId: verificationId!, - smsCode: smsCode, - ); - await FirebaseAuth.instance.signInWithCredential(credential); - onSuccess(); - } catch (e) { - onError('Invalid verification code.'); - } - } - - // --- Backend Wrappers --- - - Future registerWithPin(String pin) async { - final rawPhone = phone.replaceAll(' ', ''); - final parts = (pendingFirstName ?? 'User').split(' '); - final first = parts.first; - final last = parts.length > 1 ? parts.sublist(1).join(' ') : ''; - - double lat = 0.0; - double lng = 0.0; - - try { - bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); - if (serviceEnabled) { - LocationPermission permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - } - if (permission == LocationPermission.whileInUse || permission == LocationPermission.always) { - Position position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.medium); - lat = position.latitude; - lng = position.longitude; - } - } - } catch (e) { - debugPrint('Error getting location for registration: $e'); - } - - final token = await _fetchDeviceToken(); - - final res = await http.post( - Uri.parse(ApiConstants.register), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - "firstname": first, - "lastname": last, - "phone": rawPhone, - "email": pendingEmail ?? '', - "pin": pin, - "configid": 1001, - "defaultlatitude": lat, - "defaultlongitude": lng, - if (token != null) "device_token": token, - }), - ); - if (res.statusCode >= 200 && res.statusCode < 300) { - final data = jsonDecode(res.body); - authToken = data['token']; - - if (data['user'] != null && data['user']['firstname'] != null) { - firstName = data['user']['firstname']; - userName = '$firstName ${data['user']['lastname'] ?? ''}'.trim(); - } else { - firstName = pendingFirstName ?? 'User'; - userName = pendingFirstName ?? 'User'; - } - - login(); - } else { - throw Exception('Registration Failed: ${res.body}'); - } - } - - Future initiateLogin(String phoneNumber) async { - final rawPhone = phoneNumber.replaceAll(' ', ''); - setPhone(phoneNumber); - - // Easter Egg - if (rawPhone == '9876543210') { - return; - } - - final initRes = await http.post( - Uri.parse(ApiConstants.login), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({"phone": rawPhone, "configid": 1001}), - ); - - if (initRes.statusCode < 200 || initRes.statusCode >= 300) { - throw Exception('Phone number not registered. Please sign up first.'); - } - } - - Future verifyPinWithBackend(String pin) async { - final rawPhone = phone.replaceAll(' ', ''); - - // Easter Egg - if (rawPhone == '9876543210' && pin == '1234') { - authToken = 'easter-egg-token'; - firstName = 'Admin'; - userName = 'Admin User'; - login(); - return; - } - - final token = await _fetchDeviceToken(); - - final verifyRes = await http.post( - Uri.parse(ApiConstants.verifyPin), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({ - "phone": rawPhone, - "pin": pin, - "configid": 1001, - if (token != null) "device_token": token, - }), - ); - - if (verifyRes.statusCode >= 200 && verifyRes.statusCode < 300) { - final data = jsonDecode(verifyRes.body); - authToken = data['token']; - if (data['user'] != null && data['user']['firstname'] != null) { - firstName = data['user']['firstname']; - userName = '$firstName ${data['user']['lastname'] ?? ''}'.trim(); - } - login(); - } else { - throw Exception('Incorrect PIN. Please try again.'); - } - } - - Future resetPinWithBackend(String newPin) async { - final rawPhone = phone.replaceAll(' ', ''); - // TODO: Create this endpoint in Go backend - final res = await http.post( - Uri.parse('${ApiConstants.baseUrl}/customer/reset-pin'), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode({"phone": rawPhone, "new_pin": newPin}), - ); - - if (res.statusCode >= 200 && res.statusCode < 300) { - final data = jsonDecode(res.body); - if (data['token'] != null) authToken = data['token']; - login(); - } else { - throw Exception('PIN Reset Failed: ${res.body}'); - } - } - - void login() { - isLoggedIn = true; - orders.clear(); - savedAddresses.clear(); - _saveSession(); - notifyListeners(); - } - - Future logout() async { - isLoggedIn = false; - authToken = null; - firstName = 'User'; - userName = 'User'; - phone = ''; - verificationId = null; - pendingFirstName = null; - pendingEmail = null; - orders.clear(); - savedAddresses.clear(); - notifyListeners(); - final prefs = await SharedPreferences.getInstance(); - await prefs.remove('auth_token'); - await prefs.remove('first_name'); - await prefs.remove('user_name'); - await prefs.remove('phone'); - } - - // ---- Booking draft ---- - final BookingDraft draft = BookingDraft(); - void touch() => notifyListeners(); - - - // ---- Orders ---- - List orders = []; - - bool isLoadingOrders = false; - - /// Fetches real bookings from the Go backend - Future fetchOrders() async { - if (authToken == null) return; - - isLoadingOrders = true; - notifyListeners(); - - if (authToken == 'easter-egg-token') { - await Future.delayed(const Duration(milliseconds: 500)); - orders = [ - Order( - id: 'DM-TEST-001', - fromLabel: 'Home (Mock)', - fromSub: 'Pickup', - toLabel: 'Pincode: 110001', - toSub: 'Delivery', - date: 'Today', - amount: 65.0, - status: OrderStatus.active, - progress: 0.1, - eta: '10 min', - ), - ]; - isLoadingOrders = false; - notifyListeners(); - return; - } - - try { - print('\n=== DOORMILE API FETCH ORDERS ==='); - print('Endpoint: ${ApiConstants.getPickups}'); - - final res = await http.get( - Uri.parse(ApiConstants.getPickups), - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer $authToken', - }, - ); - - print('Status Code: ${res.statusCode}'); - - if (res.statusCode >= 200 && res.statusCode < 300) { - final List data = jsonDecode(res.body); - - orders = data.map((b) { - return Order( - id: b['booking_no'] ?? 'DM-UNK', - fromLabel: b['pickupaddress'] ?? 'Unknown Location', - fromSub: 'Pickup', - toLabel: b['deliverypincode'] != null ? 'Pincode: ${b['deliverypincode']}' : 'Pending Destination', - toSub: 'Delivery', - date: b['created_at']?.split('T')[0] ?? 'Today', - amount: (b['estimated_price'] ?? 0.0).toDouble(), - status: _parseBackendStatus(b['status']), - progress: b['status'] == 'Pending_Pickup' ? 0.1 : 0.5, - ); - }).toList(); - } - } catch (e) { - print('Failed to fetch orders: $e'); - } finally { - isLoadingOrders = false; - notifyListeners(); - } - } - - OrderStatus _parseBackendStatus(String? status) { - if (status == 'Pending_Pickup') return OrderStatus.active; - if (status == 'Delivered') return OrderStatus.delivered; - if (status == 'Cancelled') return OrderStatus.cancelled; - return OrderStatus.active; - } - - /// Fetches estimated pricing from the Redis-backed Go API - Future fetchPriceEstimate() async { - // Map UI categories to Backend enums - String backendCategory = "General"; - switch (draft.parcelType) { - case "General Goods": backendCategory = "General"; break; - case "Books & Documents": backendCategory = "Documents"; break; - case "Electronics & Gadgets": backendCategory = "Electronics"; break; - case "Clothing & Textiles": backendCategory = "Clothing"; break; - case "Fragile Items": backendCategory = "Fragile"; break; - case "Medical & Pharma": backendCategory = "Medical"; break; - case "Automotive Parts": backendCategory = "Automotive"; break; - case "Food & Perishables": backendCategory = "Food"; break; - default: backendCategory = "General"; - } - - final payload = { - "zone": "Local", - "destinationpin": draft.dropPincode, - "pickuppincode": draft.pickupPincode, - "service_type": draft.service.isNotEmpty ? draft.service : "Normal", - "weight": 1.0, // Default since we removed it from UI - "category": backendCategory - }; - - print('\n=== DOORMILE API PRICING REQUEST ==='); - print('Endpoint: ${ApiConstants.checkPrice}'); - print('Payload: ${jsonEncode(payload)}'); - print('====================================\n'); - - if (authToken == 'easter-egg-token') { - await Future.delayed(const Duration(milliseconds: 500)); - draft.minPrice = 50.0; - draft.maxPrice = 120.0; - notifyListeners(); - return; - } - - try { - final res = await http.post( - Uri.parse(ApiConstants.checkPrice), - headers: {'Content-Type': 'application/json'}, - body: jsonEncode(payload), - ); - - print('\n=== DOORMILE API PRICING RESPONSE ==='); - print('Status Code: ${res.statusCode}'); - print('Response Body: ${res.body}'); - print('=============================\n'); - - if (res.statusCode >= 200 && res.statusCode < 300) { - final data = jsonDecode(res.body); - if (data['results'] != null && data['results'].isNotEmpty) { - final result = data['results'][0]; - draft.minPrice = (result['min_price'] as num).toDouble(); - draft.maxPrice = (result['max_price'] as num).toDouble(); - notifyListeners(); - } - } - } catch (e) { - print('Pricing API error: $e'); - // On error, we just keep the default 45 - 86 fallback - } - } - - /// Confirms the current draft into a new active order. - Future placeOrder() async { - // Map UI categories to Backend enums - String backendCategory = "General"; - switch (draft.parcelType) { - case "General Goods": backendCategory = "General"; break; - case "Books & Documents": backendCategory = "Documents"; break; - case "Electronics & Gadgets": backendCategory = "Electronics"; break; - case "Clothing & Textiles": backendCategory = "Clothing"; break; - case "Fragile Items": backendCategory = "Fragile"; break; - case "Medical & Pharma": backendCategory = "Medical"; break; - case "Automotive Parts": backendCategory = "Automotive"; break; - case "Food & Perishables": backendCategory = "Food"; break; - default: backendCategory = "General"; - } - - final payload = { - "pickupaddress": draft.pickupAddress.isNotEmpty ? draft.pickupAddress : "Current Location (GPS)", - "pickuppincode": draft.pickupPincode, - "pickuplatitude": draft.pickupLatitude, - "pickuplongitude": draft.pickupLongitude, - "deliverypincode": draft.dropPincode, - "deliveryaddress": draft.dropAddress, - "serviceoption": draft.service, - "contactname": draft.contactName, - "contactmobile": draft.contactMobile, - "parcels": [ - { - "itemcategory": backendCategory, - "itemdescription": draft.notes.isNotEmpty ? draft.notes : "No description provided" - } - ] - }; - - print('\n=== DOORMILE API BOOKING REQUEST ==='); - print('Endpoint: ${ApiConstants.createPickup}'); - print('Payload: ${jsonEncode(payload)}'); - print('====================================\n'); - - if (authToken == 'easter-egg-token') { - await Future.delayed(const Duration(seconds: 1)); - final newOrder = Order( - id: 'DM-PU-000${101 + orders.length}', - fromLabel: draft.pickupAddress, - fromSub: draft.contactName.isEmpty ? 'Pickup requested' : 'Contact: ${draft.contactName}', - toLabel: draft.dropPincode.isNotEmpty ? 'Pincode: ${draft.dropPincode}' : 'Awaiting Miler input', - toSub: 'Drop details pending', - date: 'Today', - amount: draft.minPrice, - status: OrderStatus.active, - progress: 0.1, - eta: 'Assigning Miler...', - ); - - orders.insert(0, newOrder); - draft.reset(); - notifyListeners(); - return newOrder; - } - - final res = await http.post( - Uri.parse(ApiConstants.createPickup), - headers: { - 'Content-Type': 'application/json', - if (authToken != null) 'Authorization': 'Bearer $authToken', - }, - body: jsonEncode(payload), - ); - - print('\n=== DOORMILE API RESPONSE ==='); - print('Status Code: ${res.statusCode}'); - print('Response Body: ${res.body}'); - print('=============================\n'); - - if (res.statusCode >= 200 && res.statusCode < 300) { - final data = jsonDecode(res.body); - - final newOrder = Order( - id: data['booking_no'] ?? 'DM-PU-000${101 + orders.length}', - fromLabel: draft.pickupAddress, - fromSub: draft.contactName.isEmpty ? 'Pickup requested' : 'Contact: ${draft.contactName}', - toLabel: draft.dropPincode.isNotEmpty ? 'Pincode: ${draft.dropPincode}' : 'Awaiting Miler input', - toSub: 'Drop details pending', - date: 'Today', - amount: draft.minPrice, - status: OrderStatus.active, - progress: 0.1, - eta: 'Assigning Miler...', - ); - - orders.insert(0, newOrder); - draft.reset(); - notifyListeners(); - return newOrder; - } else { - throw Exception('Failed to create pickup request: ${res.body}'); - } - } - - // ---- Saved addresses ---- - List savedAddresses = [ - AddressEntry( - label: 'Home', - line: 'Sector 45, Gurgaon, Haryana, 122003', - icon: Icons.home_rounded, - iconBg: AppColors.primaryFixed, - iconColor: AppColors.primary, - pincode: '122003', - ), - AddressEntry( - label: 'Work', - line: 'Cyber City Building 10, DLF Phase 2', - icon: Icons.work_rounded, - iconBg: AppColors.tertiaryFixed, - iconColor: AppColors.tertiary, - pincode: '122002', - ), - AddressEntry( - label: 'Select City Walk Mall', - line: 'Saket District Centre, New Delhi', - icon: Icons.history_rounded, - iconBg: AppColors.surfaceContainerHigh, - iconColor: AppColors.onSurfaceVariant, - pincode: '110017', - ), - ]; - - -} diff --git a/lib/state/app_scope.dart b/lib/state/app_scope.dart new file mode 100644 index 0000000..319100f --- /dev/null +++ b/lib/state/app_scope.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; + +import 'app_state.dart'; + +/// Makes [AppState] available to the widget tree and rebuilds dependents when +/// it changes. Deliberately dependency-free — no external state package. +class AppScope extends InheritedNotifier { + const AppScope({super.key, required AppState state, required super.child}) + : super(notifier: state); + + /// Subscribes the calling widget to changes. + static AppState of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'AppScope.of() called outside an AppScope'); + return scope!.notifier!; + } + + /// Reads without subscribing — for callbacks and one-shot lookups. + static AppState read(BuildContext context) { + final scope = context.getInheritedWidgetOfExactType(); + assert(scope != null, 'AppScope.read() called outside an AppScope'); + return scope!.notifier!; + } +} diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart new file mode 100644 index 0000000..fbbafd3 --- /dev/null +++ b/lib/state/app_state.dart @@ -0,0 +1,728 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import '../data/app_config.dart'; +import '../data/doormile_api.dart'; +import '../data/location_service.dart'; +import '../data/models.dart'; + +/// Single source of truth for the customer app. +/// +/// Screens read from here and call these methods; nothing mutates a [Booking] +/// directly. +/// +/// **Every booking on screen came from the server.** This class has no way to +/// invent one and no way to move one along: a stage changes when the backend +/// says it changed and [refreshBooking] reads it back. +class AppState extends ChangeNotifier { + AppState({DoormileApi? api, LocationService? location}) + : api = api ?? DoormileApi.instance, + location = location ?? LocationService.instance { + this.api.onSessionLost = onSessionLost; + loadLimits(); + } + + final DoormileApi api; + final LocationService location; + + // ------------------------------------------------------------- session + + Customer? customer; + + /// **The authoritative pickup point.** Display address *and* the coordinates + /// the Miler is sent to, in one object. + /// + /// All three ways of choosing a pickup — the device fix, a map drag, and a + /// search result — write this single field, so nothing downstream has to + /// reconcile "the address" against "the pin". + Place? pickup; + + /// True once we have both an address and coordinates to send a Miler to. + /// The booking step gates on this rather than on the address alone. + bool get hasPickupPoint => pickup?.hasLocation ?? false; + + /// True while a pin is being turned into an address. The pickup card shows a + /// placeholder rather than a stale street name. + bool resolvingPickup = false; + + /// Why we could not read the device location, if we could not. Null when the + /// pin came from GPS or from the customer. + LocationDenial? locationDenial; + + /// Where the **customer** is, as opposed to where the pickup pin is. + /// + /// Kept separate on purpose: the pin moves when they drag the map, this does + /// not. It is what the blue dot renders at, and it survives a drag so they + /// can always see how far they have moved the pin from themselves. + double? deviceLat; + double? deviceLng; + + /// Horizontal accuracy in metres, when the platform reported it — the halo + /// around the dot. + double? deviceAccuracy; + + bool get hasDeviceLocation => deviceLat != null && deviceLng != null; + + /// Guards against an earlier reverse-geocode landing after a later drag. + int _pinSeq = 0; + + /// Where the map should open: the current pin, else the service-area default. + double get mapLat => pickup?.lat ?? LocationService.fallbackLat; + double get mapLng => pickup?.lng ?? LocationService.fallbackLng; + + bool get isSignedIn => customer != null; + + /// True until the stored session has been looked for. The app shows its + /// launch state rather than the sign-in screen while this is set, so a + /// returning customer never sees a login form flash past. + bool restoringSession = true; + + void signIn(Customer c) { + customer = c; + notifyListeners(); + unawaited(refreshOrders()); + } + + /// Looks for a persisted session at launch. + /// + /// Never throws: being offline at launch is not being signed out, and the + /// stored customer stands until a call actually comes back 401. + Future restoreSession() async { + try { + final restored = await api.restoreSession(); + if (restored != null) { + customer = restored; + unawaited(refreshOrders()); + } else if (AppConfig.hasAutoLogin) { + await _autoSignIn(); + } else if (AppConfig.devAutoLogin) { + // Dev builds only, and only when asked with DM_DEV_LOGIN: open straight + // into the app without the login screen. Guarded in [AppConfig] so no + // define can do this in a release. Without the flag, a dev build still + // lands on login, where any 4-digit code signs in. + debugPrint('[AUTH] DM_DEV_LOGIN — opening without the login screen'); + signIn( + const Customer( + id: 'cust_dev', + name: 'Dev Customer', + phone: '+91 98765 43210', + email: 'dev@doormile.com', + ), + ); + } + } catch (e) { + // Same rule as the limits call: this decides which screen opens, so it + // fails to "signed out", never to a crash. + debugPrint('[SESSION] restore failed: $e'); + } finally { + restoringSession = false; + notifyListeners(); + } + } + + /// Signs in for real, without the login screen — `DM_LOGIN_AS`. + /// + /// This is a genuine sign-in, not a bypass: it runs the same two calls the + /// login and code screens run, and what it ends up holding is a token the + /// server issued. That is the whole point of it existing next to + /// [AppConfig.useDevData] — a build that skips the entrance still has to end + /// up as a real customer, or every booking it makes is a fake one. + /// + /// Failure is not fatal and never throws: the app simply lands on the login + /// screen, which is exactly where someone whose code was refused should be. + Future _autoSignIn() async { + final identifier = AppConfig.autoLoginIdentifier; + try { + // The request may come back `sent: false` on the resend cooldown; that + // is fine, the code already issued is still the one Redis is holding. + await api.sendOtp(identifier); + final verified = await api.verifyOtp(identifier, AppConfig.autoLoginCode); + customer = verified; + unawaited(refreshOrders()); + debugPrint('[AUTH] DM_LOGIN_AS — signed in as $identifier'); + } catch (e) { + debugPrint('[AUTH] DM_LOGIN_AS failed for $identifier: $e'); + } + } + + /// Called by the API layer when a refresh fails — the chain is dead, so the + /// customer is signed out wherever they happen to be standing. + void onSessionLost() { + if (customer == null) return; + debugPrint('[SESSION] lost, signing out'); + signOut(); + } + + Future signOut() async { + customer = null; + orders.clear(); + pickup = null; + deviceLat = null; + deviceLng = null; + deviceAccuracy = null; + locationDenial = null; + ordersCursor = null; + clearCaches(); + notifyListeners(); + try { + await api.signOut(); + } on ApiException catch (e) { + // Local sign-out has already happened and must stand. + debugPrint('[SESSION] sign-out call failed: $e'); + } + } + + /// Reads the device location and turns it into an address. + /// + /// Never throws and never leaves the customer stuck: a refused permission + /// falls back to the service-area centre with [locationDenial] set, so the + /// screen can explain it and offer search instead. + Future detectPickupLocation() async { + final seq = ++_pinSeq; + resolvingPickup = true; + notifyListeners(); + + final fix = await location.current(); + if (seq != _pinSeq) return; + locationDenial = fix.denial; + + if (fix.hasFix) { + deviceLat = fix.lat; + deviceLng = fix.lng; + deviceAccuracy = fix.accuracy; + } + + final lat = fix.lat ?? pickup?.lat ?? LocationService.fallbackLat; + final lng = fix.lng ?? pickup?.lng ?? LocationService.fallbackLng; + await _resolvePin(lat, lng, seq); + } + + /// Called when the map settles under the pin. The newest drag always wins. + Future setPickupFromPin(double lat, double lng) async { + final seq = ++_pinSeq; + resolvingPickup = true; + notifyListeners(); + await _resolvePin(lat, lng, seq); + } + + Future _resolvePin(double lat, double lng, int seq) async { + Place resolved; + try { + resolved = await api.reverseGeocode(lat: lat, lng: lng); + } on ApiException { + resolved = Place( + title: 'Pinned location', + sub: 'Address unavailable — the Miler will confirm it at pickup', + lat: lat, + lng: lng, + ); + } + if (seq != _pinSeq) return; + pickup = resolved; + resolvingPickup = false; + notifyListeners(); + } + + /// Adopts a place chosen from search. Cancels any in-flight pin resolution, + /// so the newest customer intent always wins — same rule as a drag. + void setPickup(Place place) { + _pinSeq++; + // A search result without coordinates cannot be a pin. The backend + // contract requires them (§6.2); if one arrives without, keep the address + // and hold the previous pin rather than dropping the customer somewhere + // else on the map. + assert( + place.hasLocation, + 'searchPlaces returned a Place with no coordinates: ${place.title}', + ); + pickup = place.hasLocation + ? place + : Place( + title: place.title, + sub: place.sub, + lat: pickup?.lat, + lng: pickup?.lng, + ); + resolvingPickup = false; + locationDenial = null; + notifyListeners(); + } + + /// Opens OS settings after a permanent refusal. + Future openLocationSettings() => location.openSettings(); + + // ------------------------------------------------- service delegation + // + // Screens call these, never `DoormileApi.instance`. That keeps the injected + // instance authoritative and means no widget can tell whether an answer came + // from the backend. + + Future sendOtp(String identifier) => api.sendOtp(identifier); + + Future signUp({ + required String name, + required String phone, + String? email, + }) => api.signUp(name: name, phone: phone, email: email); + + /// Verifies the code and adopts the session in one step, so no screen has to + /// remember to call [signIn] afterwards. + Future verifyOtp( + String identifier, + String code, { + String? name, + }) async { + final verified = await api.verifyOtp(identifier, code, name: name); + signIn(verified); + return verified; + } + + /// Biased towards the pickup point the customer is working with, so a search + /// for a street name answers with the one in their city first. + Future> searchPlaces(String query) { + final near = draftPickup ?? pickup; + return api.searchPlaces(query, lat: near?.lat, lng: near?.lng); + } + + /// Backend policy on cancellation; the UI only asks. + bool isCancellable(JourneyStage stage) => api.isCancellable(stage); + + /// The window a booking was placed in. + /// + /// Served from whatever [loadSlots] last returned, which is why the index is + /// never cleared: a booking made an hour ago must still be able to name its + /// window after the slot list has rolled over. + PickupSlot? slotById(String? id) => + id == null ? null : _slotIndex[id] ?? api.slotById(id); + + final Map _slotIndex = {}; + + // --------------------------------------------------------------- limits + + /// Caps on one pickup. Backend-owned; the UI never hardcodes them. + BookingLimits limits = const BookingLimits(); + + /// Caps are allowed to vary by city, so the pickup point goes with the ask. + Future loadLimits() async { + try { + limits = await api.getBookingLimits(pickup: pickup); + notifyListeners(); + } catch (e) { + // The safe cap is already in place; a failed fetch must not block + // booking. Catching everything, not just ApiException: this runs from + // the constructor, and a malformed response is not worth a dead app. + debugPrint('[LIMITS] keeping defaults: $e'); + } + } + + // -------------------------------------------------------- booking draft + + /// The in-progress pickup. One destination is the common case; the customer + /// can add more, all collected in the same visit. + Place? draftPickup; + List draftDestinations = [DestinationGroup()]; + String? draftSlotId; + FareEstimate? draftFare; + + int get draftTotalPackages => + draftDestinations.fold(0, (sum, d) => sum + d.packageCount); + + bool get canAddDestination => + draftDestinations.length < limits.maxDestinations && + draftTotalPackages < limits.maxPackages && + draftDestinations.every((d) => d.isComplete); + + bool canAddPackageTo(int index) => draftTotalPackages < limits.maxPackages; + + /// Every destination has a serviceable state and district. + bool get draftReady => + draftDestinations.isNotEmpty && + draftDestinations.every((d) => d.isComplete); + + /// One key per booking *intent*, held across every retry of it. + /// + /// A retried `POST /customer/bookings` carrying the same key replays the + /// original response instead of creating a second pickup; a new key is a new + /// booking. So it is minted once here, when the customer starts filling the + /// form, and cleared only when a booking actually lands. + String? _bookingIdempotencyKey; + + void startBooking() { + draftPickup = pickup; + draftDestinations = [DestinationGroup()]; + draftSlotId = null; + draftFare = null; + districtCache.clear(); + // Slots carry their own date. A session left open across midnight would + // otherwise offer yesterday's windows, and booking one is refused — so the + // list is re-read for every booking rather than kept for the session. + _invalidateSlots(); + _bookingIdempotencyKey = null; + notifyListeners(); + } + + /// Whether "Send to another place" is offered at all. + /// + /// Multi-destination is server-gated while the Miler app still keys its work + /// on a booking-level id: a fanned-out pickup would give the rider one stop + /// for three parcels. The backend advertises the cap, the client obeys it, + /// and a cap of 1 hides the control rather than showing a dead one. + bool get allowsMultipleDestinations => limits.allowsMultipleDestinations; + + void selectStateFor(int index, ServiceArea area) { + draftDestinations[index].destination + ..stateCode = area.code + ..stateName = area.name + ..districtCode = null + ..districtName = null; + draftDestinations[index].district = null; + draftFare = null; + notifyListeners(); + } + + void selectDistrictFor(int index, District district) { + draftDestinations[index] + ..destination.districtCode = district.code + ..destination.districtName = district.name + ..district = district; + notifyListeners(); + _loadFare(); + } + + void setPackageCount(int index, int count) { + final group = draftDestinations[index]; + final others = draftTotalPackages - group.packageCount; + group.packageCount = count.clamp(1, limits.maxPackages - others); + notifyListeners(); + _loadFare(); + } + + /// Returns false when the caps say no, so the caller can explain why. + bool addDestination() { + if (!canAddDestination) return false; + draftDestinations.add(DestinationGroup()); + notifyListeners(); + return true; + } + + void removeDestination(int index) { + if (draftDestinations.length <= 1) return; + draftDestinations.removeAt(index); + notifyListeners(); + _loadFare(); + } + + /// Republish after something mutated the draft in place (the details sheet). + void touch() => notifyListeners(); + + void selectSlot(PickupSlot slot) { + draftSlotId = slot.id; + notifyListeners(); + } + + void setDraftPin(int index, MapPin? pin) { + draftDestinations[index].details.pin = pin; + notifyListeners(); + } + + /// Priced as soon as the route is known, so Review has it ready. + Future _loadFare() async { + final from = draftPickup ?? pickup; + if (from == null || !draftReady) return; + try { + draftFare = await api.estimateFare( + pickup: from, + destinations: draftDestinations, + ); + notifyListeners(); + } on ApiException { + // A missing estimate is not worth blocking the booking over. + } + } + + Future confirmBooking() async { + final key = _bookingIdempotencyKey ??= _newIdempotencyKey(); + try { + final booking = await api.createBooking( + pickup: draftPickup ?? pickup!, + destinations: draftDestinations, + slotId: draftSlotId, + fare: draftFare, + idempotencyKey: key, + ); + // The intent is spent. A further booking needs a new key or the server + // would replay this one. + _bookingIdempotencyKey = null; + orders.insert(0, booking); + activeReference = booking.reference; + notifyListeners(); + return booking; + } on ApiException catch (e) { + // The slot went stale — filled up, or its window passed while the form + // was open. Both recover by re-reading the list, so drop the cached one + // here and let the screen send them back to slot selection. + if (e.needsFreshSlots) { + _invalidateSlots(); + draftSlotId = null; + notifyListeners(); + } + rethrow; + } + } + + String _newIdempotencyKey() { + final now = DateTime.now().microsecondsSinceEpoch.toRadixString(36); + final salt = Object().hashCode.toRadixString(36); + return '$now-$salt'; + } + + // -------------------------------------------------------------- orders + + final List orders = []; + String? activeReference; + BookingStatus ordersTab = BookingStatus.active; + + Booking? byReference(String? reference) { + if (reference == null) return null; + for (final o in orders) { + if (o.reference == reference) return o; + } + return null; + } + + Booking? get trackedBooking => byReference(activeReference); + + List get activeBookings => + orders.where((o) => o.status == BookingStatus.active).toList(); + + List ofStatus(BookingStatus status) => + orders.where((o) => o.status == status).toList(); + + /// Orders as the customer owns them: one row per pickup before collection, + /// one row per destination once the backend has created those orders. + List entriesOf(BookingStatus status) => [ + for (final booking in ofStatus(status)) + if (!booking.hasOrders) + OrderEntry(booking) + else + for (var i = 0; i < booking.destinations.length; i++) + OrderEntry(booking, i), + ]; + + List get recentEntries => [ + for (final booking in orders.where((o) => o.status != BookingStatus.active)) + if (!booking.hasOrders) + OrderEntry(booking) + else + for (var i = 0; i < booking.destinations.length; i++) + OrderEntry(booking, i), + ].take(3).toList(); + + /// Everything no longer running, newest first — Home's "Recent". + List get recent => + orders.where((o) => o.status != BookingStatus.active).take(3).toList(); + + /// Keyset cursor for the next page of orders; null when the last page has + /// been read or none has been read yet. + String? ordersCursor; + + /// True while [loadMoreOrders] is in flight, so a scroll cannot start two. + bool loadingMoreOrders = false; + + bool get hasMoreOrders => ordersCursor != null && ordersCursor!.isNotEmpty; + + /// Pull-to-refresh on Home and Orders — reads the first page again. + /// + /// The list is the server's: whatever comes back replaces what is on screen, + /// including an empty page, which is a real answer and not a failure. + Future refreshOrders() async { + try { + final page = await api.getBookingPage(); + // An empty first page is a real answer: this customer has no bookings. + // The list is the server's, so it is adopted whole rather than merged. + orders + ..clear() + ..addAll(page.bookings); + ordersCursor = page.nextCursor; + } on ApiException { + // A failed refresh leaves the last good list on screen. + } + notifyListeners(); + } + + /// Appends the next keyset page. Keyset rather than offset, so a booking + /// arriving at the top mid-scroll cannot push a row into a second page and + /// show it twice. + Future loadMoreOrders() async { + if (!hasMoreOrders || loadingMoreOrders) return; + loadingMoreOrders = true; + notifyListeners(); + try { + final page = await api.getBookingPage(cursor: ordersCursor); + final seen = orders.map((o) => o.reference).toSet(); + orders.addAll(page.bookings.where((b) => !seen.contains(b.reference))); + ordersCursor = page.nextCursor; + } on ApiException { + // Keep the rows already on screen; the cursor stays put so the next + // scroll retries the same page rather than skipping it. + } finally { + loadingMoreOrders = false; + notifyListeners(); + } + } + + void openTracking(String reference) { + activeReference = reference; + notifyListeners(); + unawaited(refreshBooking(reference)); + } + + /// Re-reads one booking. Null means the server answered `304` — nothing + /// moved — so the copy already on screen stands. + Future refreshBooking(String reference) async { + try { + final fresh = await api.getBooking(reference); + if (fresh == null) return; + final at = orders.indexWhere((o) => o.reference == reference); + if (at == -1) { + orders.insert(0, fresh); + } else { + orders[at] = fresh; + } + notifyListeners(); + } on ApiException { + // Keep showing what we have. + } + } + + void setOrdersTab(BookingStatus status) { + ordersTab = status; + notifyListeners(); + } + + /// Sends the optional address and recipient for an existing booking. + /// + /// Accepted until the parcels are collected; after that the server answers + /// `409` and the Miler's record at the door is the one that counts. + Future saveDestinationDetails(Booking booking, int index) async { + if (index < 0 || index >= booking.destinations.length) return; + final group = booking.destinations[index]; + await api.updateDestinationDetails( + booking.reference, + // The server's own position for this stop, which is what the route + // addresses. The list position is only a fallback for a payload that + // did not send one. + group.index ?? index, + group.details, + ); + notifyListeners(); + } + + // ---------------------------------------------------------------- push + + /// Registers this device for milestone notifications. + /// + /// Nothing calls this yet: no push SDK is wired in, so the app has no token + /// to hand over. The endpoint is reached through here so that adding one is + /// a two-line change rather than a new path through the state layer. + Future registerPushToken(String token) async { + try { + await api.registerDevice(token); + } on ApiException catch (e) { + debugPrint('[PUSH] could not register device: $e'); + } + } + + Future unregisterPushToken(String token) async { + try { + await api.unregisterDevice(token); + } on ApiException catch (e) { + debugPrint('[PUSH] could not unregister device: $e'); + } + } + + // ------------------------------------------------- stage transitions + + /// What the tracking screen's QA stepper calls. + /// + /// It asks the server's own helper to move the booking, then re-reads it — + /// so what appears on screen is the stage the **server** recorded, never one + /// the client applied to its own copy. + Future advanceStage(Booking booking, JourneyStage target) async { + try { + await api.setStage(booking.reference, target); + await refreshBooking(booking.reference); + } on ApiException catch (e) { + debugPrint('[OPS] stage override refused: $e'); + } + } + + Future cancelBooking(Booking booking, String? reason) async { + await api.cancelBooking(booking.reference, reason); + booking + ..status = BookingStatus.cancelled + ..cancelReason = reason + ..cancellable = false; + notifyListeners(); + } + + // ------------------------------------------------- serviceability cache + + List? statesCache; + final Map> districtCache = {}; + List? slotsCache; + + /// Only states we can actually serve reach the picker. A state with nothing + /// open is not an option the customer should have to read past. + Future> loadStates({bool refresh = false}) async { + if (refresh) statesCache = null; + final all = statesCache ??= await api.getServiceableStates(); + return all.where((s) => s.hasOpenDistricts).toList(); + } + + /// Districts the picker offers: serviceable ones only. The full response is + /// cached so [upcomingDistricts] can name what is opening without putting + /// disabled tiles in front of anyone. + Future> loadDistricts( + String stateCode, { + bool refresh = false, + }) async { + if (refresh) districtCache.remove(stateCode); + final all = districtCache[stateCode] ??= await api.getServiceableDistricts( + stateCode, + ); + return all.where((d) => d.available).toList(); + } + + /// Names of districts in this state that are not open yet, for a quiet + /// "Coming soon" line. Empty until the districts have been fetched. + List upcomingDistricts(String stateCode) => (districtCache[stateCode] ?? const []) + .where((d) => !d.available) + .map((d) => d.name) + .toList(); + + /// Bumped whenever [slotsCache] is dropped, so a slot list already on screen + /// re-reads instead of showing what it fetched the first time. + int slotsEpoch = 0; + + void _invalidateSlots() { + slotsCache = null; + slotsEpoch++; + } + + Future> loadSlots({bool refresh = false}) async { + if (refresh) slotsCache = null; + final slots = slotsCache ??= await api.getPickupSlots(pickup: draftPickup); + for (final slot in slots) { + _slotIndex[slot.id] = slot; + } + return slots; + } + + void clearCaches() { + statesCache = null; + districtCache.clear(); + _invalidateSlots(); + } +} diff --git a/lib/ui/format.dart b/lib/ui/format.dart new file mode 100644 index 0000000..40f0973 --- /dev/null +++ b/lib/ui/format.dart @@ -0,0 +1,80 @@ +/// Small formatting helpers shared across screens. +library; + +String greetingForNow([DateTime? now]) { + final h = (now ?? DateTime.now()).hour; + if (h < 12) return 'Good morning'; + if (h < 17) return 'Good afternoon'; + return 'Good evening'; +} + +/// "Just now" / "18 min ago" / "3 hr ago" / "5 d ago" +String relativeTime(DateTime? at, [DateTime? now]) { + if (at == null) return ''; + final mins = (now ?? DateTime.now()).difference(at).inMinutes; + if (mins < 1) return 'Just now'; + if (mins < 60) return '$mins min ago'; + final hrs = (mins / 60).round(); + if (hrs < 24) return '$hrs hr ago'; + return '${(hrs / 24).round()} d ago'; +} + +const _months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +/// "Today" / "Yesterday" / "29 Aug" +String dateLabel(DateTime date, [DateTime? now]) { + final today = now ?? DateTime.now(); + bool sameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + + if (sameDay(date, today)) return 'Today'; + final yesterday = today.subtract(const Duration(days: 1)); + if (sameDay(date, yesterday)) return 'Yesterday'; + return '${date.day} ${_months[date.month - 1]}'; +} + +/// "SEPTEMBER 2026" — the eyebrow over a tab root's title. Spelled in full +/// rather than abbreviated: it is read once, at the top of the screen, and +/// "SEP 2026" saves four characters nobody needed saved. +String monthLabel(DateTime date) => '${_monthsFull[date.month - 1]} ${date.year}'; + +const _monthsFull = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +/// Initials for an avatar, e.g. "Joe Oommen" -> "JO". +String initialsOf(String? name) { + final n = (name ?? '').trim(); + if (n.isEmpty) return 'DM'; + final letters = n + .split(RegExp(r'\s+')) + .map((p) => p.isEmpty ? '' : p[0]) + .join(); + return letters + .substring(0, letters.length < 2 ? letters.length : 2) + .toUpperCase(); +} diff --git a/lib/ui/screens/account_screen.dart b/lib/ui/screens/account_screen.dart new file mode 100644 index 0000000..a89fbe5 --- /dev/null +++ b/lib/ui/screens/account_screen.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../data/app_config.dart'; +import '../../state/app_scope.dart'; +import '../format.dart'; +import '../tokens.dart'; +import '../widgets/buttons.dart'; +import '../widgets/cards.dart'; +import '../widgets/chrome.dart'; +import '../widgets/feedback.dart'; +import '../widgets/inputs.dart'; +import '../widgets/misc.dart'; +import 'auth/login_screen.dart'; +import 'sheets/place_search_sheet.dart'; + +/// Account — profile, preferences, and the build's environment line. +/// +/// The profile folds into the brand header: the avatar takes the slot the other +/// tab roots give an action, the customer's name takes the slot the title holds +/// elsewhere, and the phone number sits in the recessed track. That removes the +/// profile *card* the screen used to open with — a card whose entire content +/// was three facts about the person already looking at the screen. +class AccountScreen extends StatelessWidget { + const AccountScreen({super.key}); + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + final customer = app.customer; + + // Empty rather than a stand-in name: this screen is only ever built for a + // signed-in customer, and a placeholder here would read as somebody's + // actual account. + final name = customer?.name ?? ''; + final phone = customer?.phone ?? ''; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DmBrandHeader( + eyebrow: 'Account', + title: name.isEmpty ? 'Your account' : name, + leading: DmAvatar(initials: initialsOf(name), onBrand: true), + action: DmIconButton( + icon: LucideIcons.circleHelp, + tooltip: 'Help', + background: DmColors.onBrandRaise, + foreground: Colors.white, + border: Colors.transparent, + radius: DmRadius.lg, + size: 42, + onPressed: () => DmToast.show(context, 'Support is on the way'), + ), + child: phone.isEmpty + ? null + : DmHeaderTrack( + trailing: DmTextAction( + label: 'Edit', + color: Colors.white, + minHeight: 24, + onPressed: () => DmToast.show(context, 'Editing is coming soon'), + ), + child: Text( + phone, + style: DmText.monoLg.copyWith(fontSize: 14.5, color: Colors.white), + ), + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 28), + children: [ + const DmMicroHead('Preferences', top: 16), + DmRowGroup( + children: [ + DmRow( + icon: LucideIcons.mapPin, + label: 'Saved addresses', + value: app.pickup?.title ?? 'None', + showChevron: true, + onTap: () async { + final place = await showPlaceSearchSheet(context); + if (place != null && context.mounted) { + app.setPickup(place); + DmToast.show(context, 'Pickup location updated'); + } + }, + ), + const DmRow( + icon: LucideIcons.bell, + label: 'Notifications', + value: 'SMS and push', + showChevron: true, + ), + const DmRow( + icon: LucideIcons.wallet, + label: 'Payment methods', + value: 'UPI', + showChevron: true, + ), + const DmRow( + icon: LucideIcons.circleHelp, + label: 'Help and support', + showChevron: true, + ), + ], + ), + // The environment line is not a control — it is what a bug + // report needs in order to be actionable. Always shown when the + // session came from a dev token rather than a sign-in. + if (!AppConfig.isProd || AppConfig.authBypassed) ...[ + const DmMicroHead('Build', brand: false), + DmCard( + tone: DmCardTone.sunken, + children: [ + DmCardCell( + // Not a DmRow: that lays a short label against a short + // value on one line, and this value is a URL plus every + // flag the build carries. It overflowed by 59px the + // moment a bypass was on — which is exactly when someone + // needs to read it. + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('API', style: DmText.eyebrow), + const SizedBox(height: 6), + Text( + AppConfig.describe, + style: DmText.mono.copyWith( + height: 1.5, + color: DmColors.ink2, + ), + ), + ], + ), + ), + ], + ), + ], + const SizedBox(height: 24), + DmButton( + label: 'Sign out', + kind: DmButtonKind.ghost, + danger: true, + onPressed: () { + app.signOut(); + Navigator.of(context).pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const LoginScreen()), + (route) => false, + ); + }, + ), + const SizedBox(height: 6), + Text.rich( + TextSpan( + style: DmText.tiny.copyWith(color: DmColors.ink4), + children: [ + const TextSpan(text: 'Doormile Customer '), + TextSpan( + text: 'v1.0.0', + style: DmText.monoSm.copyWith(color: DmColors.ink4), + ), + ], + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/ui/screens/auth/auth_scaffold.dart b/lib/ui/screens/auth/auth_scaffold.dart new file mode 100644 index 0000000..e1bbaaa --- /dev/null +++ b/lib/ui/screens/auth/auth_scaffold.dart @@ -0,0 +1,440 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter/services.dart'; + +import '../../tokens.dart'; +import '../../widgets/chrome.dart'; + +/// Shared chrome for the auth screens: a brand panel at the top, a white sheet +/// below holding the form. +/// +/// Auth is the only place the app goes full-brand — it sets the tone once, and +/// the product itself stays quiet after that. +/// Shared chrome for the auth screens: a full-bleed brand block, and a rounded +/// sheet lifted over it holding the form. +/// +/// The frame matches the Miler app's onboarding, deliberately — the two apps +/// are the same company arriving from two directions, and a customer who has +/// seen a Miler at the door should recognise the doorway. One block across sign +/// in, sign up and verify, so the three read as one entrance rather than three +/// forms that happen to follow each other. +/// +/// Content is **top-anchored, not centred**: the question and its input sit +/// high, where the keyboard cannot shove them around. The banner gives back +/// half its height the moment a field takes focus, which is what fills the +/// screen at rest without stealing room while someone is typing. +class AuthScaffold extends StatelessWidget { + const AuthScaffold({ + super.key, + this.title, + this.subtitle, + required this.children, + this.footer, + this.onBack, + this.heroTitle, + this.heroSub, + this.compactBanner = false, + this.badge = true, + }); + + /// Heading inside the white sheet, above the form. Null on the entry screen, + /// where the headline is [heroTitle] and sits on the crimson instead. + final String? title; + final String? subtitle; + + final List children; + + /// Pinned below the form, above the safe area. + final Widget? footer; + + final VoidCallback? onBack; + + /// The promise, set on the brand field itself — "Book a pickup from your + /// door." The app's first screen should say what the app is for in the app's + /// own voice, at display size, before it asks for anything. Only the entry + /// screen carries one; the screens after it have already made the promise. + final String? heroTitle; + + /// One supporting line under [heroTitle]. + final String? heroSub; + + /// Forces the short banner. For screens that raise the keyboard themselves on + /// the first frame — otherwise they render the tall block for one frame and + /// then animate it away, which is a splash screen wearing a different hat. + final bool compactBanner; + + /// The CUSTOMER label beside the mark. It belongs on the way in, where a + /// customer might wonder which Doormile app they opened; two screens later + /// they know, and it is just a word taking up the mark's room. + final bool badge; + + /// How far the sheet is pulled up over the banner. + static const double _lift = 18; + + @override + Widget build(BuildContext context) { + return AnnotatedRegion( + value: const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + ), + child: Scaffold( + backgroundColor: DmColors.canvas, + body: Column( + children: [ + Stack( + children: [ + // `Scaffold` consumes the bottom view inset, so a child inside + // `body` sees zero whether the keyboard is up or not. This + // context sits above the Scaffold and still carries the real + // value — handing it back lets the banner react to the keyboard + // without disturbing the Scaffold's own resizing. + MediaQuery( + data: MediaQuery.of(context), + child: AuthBrandBanner( + title: heroTitle, + sub: heroSub, + badge: badge, + compact: compactBanner, + back: onBack == null + ? null + : _BackButton(onPressed: onBack!), + ), + ), + ], + ), + Expanded( + child: Transform.translate( + offset: const Offset(0, -_lift), + child: Container( + width: double.infinity, + decoration: const BoxDecoration( + color: DmColors.canvas, + borderRadius: BorderRadius.vertical( + top: Radius.circular(DmRadius.xl), + ), + ), + clipBehavior: Clip.antiAlias, + child: SafeArea( + top: false, + child: Column( + // Stretch, not the default centre: the heading block is a + // Padding sized to its own text, so a centred column was + // pushing "Verify your number" into the middle of the + // sheet while the form under it ranged left. + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── The question is pinned, only the form scrolls ── + // + // Focusing the field made Flutter scroll it into view, + // which carried "Verify your number" off the top of the + // sheet: the screen asked a question and then hid it + // the moment anyone went to answer. The heading is not + // part of the scrollable content any more, so it cannot + // be scrolled away by the keyboard. + if (title != null) + Padding( + padding: const EdgeInsets.fromLTRB( + DmSpace.pad, + 26, + DmSpace.pad, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title!, style: DmText.title), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text(subtitle!, style: DmText.lede), + ], + ], + ), + ), + Expanded( + child: SingleChildScrollView( + physics: const ClampingScrollPhysics(), + padding: EdgeInsets.fromLTRB( + DmSpace.pad, + title == null ? 26 : 24, + DmSpace.pad, + 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ), + ), + ), + if (footer != null) + Padding( + padding: const EdgeInsets.fromLTRB( + DmSpace.pad, + 4, + DmSpace.pad, + 10 + _lift, + ), + child: footer!, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// The masthead every auth screen wears. +/// +/// ── Left-aligned, and it speaks ── +/// +/// This was a centred lockup floating in a fixed 210pt block: correct, and +/// completely silent. A customer opening the app for the first time was shown a +/// logo and then asked for their phone number, with nothing in between saying +/// what they were signing up to. +/// +/// The block now reads top-down like the rest of the app — lockup, then the +/// promise at display size, then one supporting line — all ranged left on the +/// same 24pt margin the sheet below uses, so the crimson field and the white +/// sheet share one column rather than looking like two different screens. +/// +/// The wordmark is **knocked out, not re-exported**: `doormile-logo.png` is +/// RGBA, so `BlendMode.srcIn` fills every non-transparent pixel white and gives +/// a clean reverse of the red original. Shipping a second white asset would +/// mean two logo files that drift; this one cannot. +class AuthBrandBanner extends StatelessWidget { + const AuthBrandBanner({ + super.key, + this.title, + this.sub, + this.back, + this.compact = false, + this.badge = true, + }); + + /// See [AuthScaffold.heroTitle]. + final String? title; + final String? sub; + + /// The back affordance, drawn above the lockup rather than floated over it. + final Widget? back; + + /// See [AuthScaffold.compactBanner]. + final bool compact; + + /// See [AuthScaffold.badge]. + final bool badge; + + /// The source wordmark is 748×100. Held explicitly so a layout change cannot + /// squash the mark. + static const double _markAspect = 748 / 100; + + @override + Widget build(BuildContext context) { + // While the keyboard is up the promise gives way: the field the customer is + // typing into matters more than the sentence they have already read. The + // lockup stays, so the screen never loses its identity mid-form. + final typing = compact || MediaQuery.viewInsetsOf(context).bottom > 0; + + // Without the badge beside it the mark is the only thing on the field, so + // it is given the room the badge was taking rather than sitting small in a + // corner of an otherwise empty block. + final markWidth = badge ? (typing ? 132.0 : 150.0) : 196.0; + + return ColoredBox( + color: DmColors.brand, + child: CustomPaint( + painter: const DmArcField(opacity: 0.16), + child: SafeArea( + bottom: false, + child: AnimatedSize( + duration: DmMotion.base, + curve: DmMotion.ease, + alignment: Alignment.topCenter, + child: Padding( + padding: EdgeInsets.fromLTRB( + DmSpace.pad, + back == null ? 14 : 6, + DmSpace.pad, + typing ? 22 : 34, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (back != null) ...[back!, const SizedBox(height: 14)], + Row( + children: [ + Flexible( + child: Semantics( + label: 'Doormile', + image: true, + child: SizedBox( + width: markWidth, + height: markWidth / _markAspect, + child: ColorFiltered( + colorFilter: const ColorFilter.mode( + Colors.white, + BlendMode.srcIn, + ), + child: Image.asset( + 'assets/images/doormile-logo.png', + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + filterQuality: FilterQuality.medium, + ), + ), + ), + ), + ), + // Which Doormile app this is. Outlined rather than + // filled: it is a label on the mark, not a status. + if (badge) ...[ + const SizedBox(width: 10), + Container( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + decoration: BoxDecoration( + border: Border.all(color: const Color(0x80FFFFFF)), + borderRadius: DmRadius.all(5), + ), + child: Text( + 'CUSTOMER', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.eyebrow.copyWith( + fontSize: 10, + color: Colors.white, + letterSpacing: 1.4, + ), + ), + ), + ], + ], + ), + if (title != null && !typing) ...[ + const SizedBox(height: 26), + // Held to a narrow measure so the headline breaks where the + // design breaks it — two short lines, not one long one that + // runs the full width of a phone. + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: Text( + title!, + // Display is inked for the canvas; on the brand field + // it knocks out white. + style: DmText.display.copyWith(color: Colors.white), + ), + ), + if (sub != null) ...[ + const SizedBox(height: 10), + Text( + sub!, + style: DmText.lede.copyWith(color: DmColors.onBrand2), + ), + ], + ], + ], + ), + ), + ), + ), + ), + ); + } +} + +class _BackButton extends StatelessWidget { + const _BackButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Material( + color: DmColors.onBrandRaise, + borderRadius: DmRadius.all(DmRadius.sm), + child: InkWell( + borderRadius: DmRadius.all(DmRadius.sm), + onTap: onPressed, + child: Ink( + width: 40, + height: 40, + decoration: BoxDecoration( + borderRadius: DmRadius.all(DmRadius.sm), + border: Border.all(color: const Color(0x4DFFFFFF)), + ), + child: const Icon( + LucideIcons.arrowLeft, + size: 19, + color: Colors.white, + ), + ), + ), + ); + } +} + +/// The fine print shared by every auth screen. +class AuthLegal extends StatelessWidget { + const AuthLegal({super.key}); + + @override + Widget build(BuildContext context) { + return Text( + "By continuing you agree to Doormile's Terms and Privacy Policy.", + textAlign: TextAlign.center, + style: DmText.tiny.copyWith(fontSize: 12, height: 1.55), + ); + } +} + +/// The cross-link between sign in and sign up. +class AuthSwitchLink extends StatelessWidget { + const AuthSwitchLink({ + super.key, + required this.question, + required this.action, + required this.onTap, + }); + + final String question; + final String action; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + question, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.small.copyWith(fontSize: 13.5), + ), + ), + InkWell( + onTap: onTap, + borderRadius: DmRadius.all(DmRadius.xs), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), + child: Text( + action, + style: DmText.label.copyWith( + fontSize: 13.5, + color: DmColors.brand, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/ui/screens/auth/login_screen.dart b/lib/ui/screens/auth/login_screen.dart new file mode 100644 index 0000000..d2db505 --- /dev/null +++ b/lib/ui/screens/auth/login_screen.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../data/doormile_api.dart'; +import '../../../state/app_scope.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/feedback.dart'; +import '../../widgets/inputs.dart'; +import '../../widgets/pieces.dart'; +import 'auth_scaffold.dart'; +import 'otp_screen.dart'; +import 'signup_screen.dart'; + +enum LoginMode { phone, email } + +/// Sign in — phone or email, then a 4-digit code. +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _identifier = TextEditingController(); + LoginMode _mode = LoginMode.phone; + bool _sending = false; + + @override + void initState() { + super.initState(); + _identifier.addListener(() => setState(() {})); + } + + @override + void dispose() { + _identifier.dispose(); + super.dispose(); + } + + bool get _isPhone => _mode == LoginMode.phone; + + bool get _valid => _isPhone + ? _identifier.text.trim().length >= 10 + : _identifier.text.contains('@'); + + Future _continue() async { + final raw = _identifier.text.trim(); + setState(() => _sending = true); + final target = _isPhone ? '+91 $raw' : raw; + try { + final challenge = await AppScope.read(context).sendOtp(target); + if (!mounted) return; + setState(() => _sending = false); + + // ── No code sent, no code screen ── + // + // `sent: false` is the server saying it accepted the request but did not + // deliver anything — the SMS gateway or the mail relay refused it. The + // resend path on the next screen has always honoured this flag; this one + // did not, and walked the customer to four empty boxes for a code that + // was never issued. Everything they can type there is wrong, and the + // failure reads to them as "I entered it wrong" rather than "nothing + // arrived". Stay here, say what happened, let them try again. + if (!challenge.sent) { + DmToast.show( + context, + 'We could not send a code to that ${_isPhone ? 'number' : 'address'}. ' + 'Try again in a moment.', + ); + return; + } + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + OtpScreen(identifier: target, challenge: challenge), + ), + ); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _sending = false); + DmToast.show(context, e.message); + } catch (_) { + // Anything the API layer did not turn into an ApiException still has to + // clear the spinner. Without this the button stays mid-send forever and + // the screen offers no way forward at all — a worse failure than the one + // being reported. + if (!mounted) return; + setState(() => _sending = false); + DmToast.show(context, 'Something went wrong. Please try again.'); + } + } + + @override + Widget build(BuildContext context) { + return AuthScaffold( + // The promise sits on the crimson, at display size, and the sheet gets + // on with the form. A "Welcome back" heading in the sheet said nothing + // the screen did not already say, and pushed the first control down. + heroTitle: 'Book a pickup from your door.', + heroSub: 'Track every parcel through to delivery.', + // The CTA is pinned, not scrolled. In the scroll area it was clipped by + // the footer the moment the keyboard came up — the one control the screen + // exists for, hidden exactly when it is needed. + footer: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DmButton( + label: 'Continue', + busy: _sending, + busyLabel: 'Sending code…', + onPressed: _valid ? _continue : null, + ), + const SizedBox(height: 10), + AuthSwitchLink( + question: 'New to Doormile?', + action: 'Create account', + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SignUpScreen()), + ), + ), + const SizedBox(height: 6), + const AuthLegal(), + ], + ), + children: [ + // The same chip the Orders filter and the address labels use. One + // selection control across the app means the customer learns it once, + // on the screen they meet first. + Row( + children: [ + for (final mode in LoginMode.values) ...[ + if (mode != LoginMode.values.first) const SizedBox(width: 8), + DmChoiceChip( + label: mode == LoginMode.phone ? 'Phone' : 'Email', + selected: _mode == mode, + expand: true, + onTap: () => setState(() { + _mode = mode; + _identifier.clear(); + }), + ), + ], + ], + ), + const SizedBox(height: 22), + DmTextField( + label: _isPhone ? 'Phone number' : 'Email address', + controller: _identifier, + hint: _isPhone ? '98765 43210' : 'you@example.com', + prefix: _isPhone ? '+91' : null, + keyboardType: _isPhone ? TextInputType.phone : TextInputType.emailAddress, + maxLength: _isPhone ? 10 : 60, + digitsOnly: _isPhone, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _valid ? _continue() : null, + onClear: _identifier.text.isEmpty ? null : () => _identifier.clear(), + ), + // Sits under the field it explains rather than floating below the + // button, where it read as a fourth thing to deal with. + const DmInfoBanner( + icon: LucideIcons.shieldCheck, + title: 'Your number stays private', + message: "We'll send a 4-digit code to keep your parcels secure.", + ), + ], + ); + } +} diff --git a/lib/ui/screens/auth/otp_screen.dart b/lib/ui/screens/auth/otp_screen.dart new file mode 100644 index 0000000..e502e48 --- /dev/null +++ b/lib/ui/screens/auth/otp_screen.dart @@ -0,0 +1,369 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../data/doormile_api.dart'; +import '../../../data/models.dart'; +import '../../../state/app_scope.dart'; +import '../../tokens.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/cards.dart'; +import '../../widgets/feedback.dart'; +import '../shell_screen.dart'; +import 'auth_scaffold.dart'; + +/// Verify the code. Shared by sign in and sign up. +/// +/// How many digits and how long until a resend are the **server's** to say — +/// they arrive on [challenge] from the call that asked for the code. Nothing +/// here is a constant that a backend change could make wrong. +class OtpScreen extends StatefulWidget { + const OtpScreen({ + super.key, + required this.identifier, + this.name, + this.challenge = OtpChallenge.unspecified, + }); + + /// The phone number or email the code was sent to. + final String identifier; + + /// Set when arriving from sign up, so the new account keeps its name. + final String? name; + + /// What the server said about this challenge. + final OtpChallenge challenge; + + @override + State createState() => _OtpScreenState(); +} + +class _OtpScreenState extends State { + late OtpChallenge _challenge = widget.challenge; + + late final _boxes = List.generate( + _challenge.codeLength, + (_) => TextEditingController(), + ); + late final _focus = List.generate( + _challenge.codeLength, + (_) => FocusNode(), + ); + + Timer? _ticker; + late int _resendIn = _challenge.resendAfterSeconds; + bool _verifying = false; + + int get _length => _boxes.length; + + String get _code => _boxes.map((c) => c.text).join(); + bool get _complete => _code.length == _length; + + @override + void initState() { + super.initState(); + _startResendTimer(); + WidgetsBinding.instance.addPostFrameCallback((_) => _focus.first.requestFocus()); + } + + void _startResendTimer() { + _ticker?.cancel(); + setState(() => _resendIn = _challenge.resendAfterSeconds); + if (_resendIn <= 0) return; + _ticker = Timer.periodic(const Duration(seconds: 1), (t) { + if (!mounted) return; + setState(() => _resendIn--); + if (_resendIn <= 0) t.cancel(); + }); + } + + @override + void dispose() { + _ticker?.cancel(); + for (final c in _boxes) { + c.dispose(); + } + for (final f in _focus) { + f.dispose(); + } + super.dispose(); + } + + Future _resend() async { + late final OtpChallenge challenge; + try { + challenge = await AppScope.read(context).sendOtp(widget.identifier); + } on ApiException catch (e) { + if (!mounted) return; + DmToast.show(context, e.message); + return; + } + if (!mounted) return; + // The box count is fixed for the life of this screen — the controllers are + // already built — but the resend window can move, so only that is adopted. + _challenge = OtpChallenge( + codeLength: _length, + resendAfterSeconds: challenge.resendAfterSeconds, + sent: challenge.sent, + ); + _startResendTimer(); + DmToast.show( + context, + challenge.sent ? 'Code sent again' : 'We could not send a code', + ); + } + + Future _verify() async { + setState(() => _verifying = true); + final app = AppScope.read(context); + final navigator = Navigator.of(context); + try { + await app.verifyOtp(widget.identifier, _code, name: widget.name); + if (!mounted) return; + app.detectPickupLocation(); + unawaited(HapticFeedback.mediumImpact()); + await navigator.pushAndRemoveUntil( + MaterialPageRoute(builder: (_) => const ShellScreen()), + (route) => false, + ); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _verifying = false); + for (final c in _boxes) { + c.clear(); + } + _focus.first.requestFocus(); + DmToast.show(context, e.message); + } + } + + /// Accepts a pasted code by spreading it across the boxes. + void _onChanged(int index, String value) { + if (value.length > 1) { + final digits = value.replaceAll(RegExp(r'\D'), ''); + for (var i = 0; i < _boxes.length; i++) { + _boxes[i].text = i < digits.length ? digits[i] : ''; + } + _focus[digits.length.clamp(0, _length - 1)].requestFocus(); + } else if (value.isNotEmpty && index < 3) { + _focus[index + 1].requestFocus(); + } else if (value.isEmpty && index > 0) { + _focus[index - 1].requestFocus(); + } + setState(() {}); + if (_complete && !_verifying) _verify(); + } + + @override + Widget build(BuildContext context) { + return AuthScaffold( + compactBanner: true, + // The mark alone, without the CUSTOMER label: by the third screen of the + // entrance nobody is still asking which app this is, and the question on + // this screen is a four-digit code, not a brand. + badge: false, + onBack: () => Navigator.of(context).maybePop(), + title: 'Verify your number', + subtitle: 'Enter the $_length-digit code we just sent.', + footer: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DmButton( + label: 'Verify and continue', + busy: _verifying, + busyLabel: 'Verifying…', + onPressed: _complete ? _verify : null, + ), + const SizedBox(height: 12), + const AuthLegal(), + ], + ), + children: [ + // The number is restated in mono so the digits can be checked one by + // one — this is the screen where a transposed digit means a code that + // never arrives — with the way back one tap away. + DmCard( + tone: DmCardTone.wash, + children: [ + DmCardCell( + padding: const EdgeInsets.fromLTRB(14, 12, 8, 12), + child: Row( + children: [ + const DmTile(icon: LucideIcons.shieldCheck, tone: DmTileTone.wash), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.identifier, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.monoLg.copyWith( + fontSize: 15.5, + color: DmColors.brandPress, + ), + ), + ), + DmTextAction( + label: 'Change', + onPressed: () => Navigator.of(context).maybePop(), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 22), + // Four equal boxes across the column, which is how the code is drawn + // in the design and how every other code field a customer has used + // behaves. Capped, though: stretched across a tablet each digit would + // sit alone in a 150-wide slab and stop looking like a digit. + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 360), + child: Row( + children: [ + for (var i = 0; i < _length; i++) ...[ + if (i > 0) const SizedBox(width: 11), + Expanded( + child: _OtpBox( + controller: _boxes[i], + focusNode: _focus[i], + onChanged: (v) => _onChanged(i, v), + ), + ), + ], + ], + ), + ), + ), + const SizedBox(height: 20), + // The countdown is mono so the seconds tick in place instead of the + // whole line reflowing every second as the glyph widths change. + Row( + children: [ + Expanded( + child: _resendIn > 0 + ? Text.rich( + TextSpan( + style: DmText.tiny, + children: [ + const TextSpan(text: 'Resend available in '), + TextSpan( + text: '0:${_resendIn.toString().padLeft(2, '0')}', + style: DmText.monoSm.copyWith( + fontSize: 13, + color: DmColors.brand, + ), + ), + ], + ), + ) + : Text('Did not get it?', style: DmText.tiny), + ), + DmTextAction( + label: 'Resend', + color: _resendIn > 0 ? DmColors.ink4 : DmColors.brand, + onPressed: _resendIn > 0 ? null : _resend, + ), + ], + ), + ], + ); + } +} + +/// One digit of the code. +/// +/// Was a filled `TextField` with no border, which read as a disabled field: +/// four grey slabs with nothing to say which one you were typing into. It is +/// the only thing on the screen, so it carries the state instead — empty is +/// outlined, focused takes the brand edge and lifts, filled tints. The +/// `TextField` inside is bare; the container owns every visual. +class _OtpBox extends StatefulWidget { + const _OtpBox({ + required this.controller, + required this.focusNode, + required this.onChanged, + }); + + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onChanged; + + @override + State<_OtpBox> createState() => _OtpBoxState(); +} + +class _OtpBoxState extends State<_OtpBox> { + @override + void initState() { + super.initState(); + widget.focusNode.addListener(_onFocus); + } + + @override + void dispose() { + widget.focusNode.removeListener(_onFocus); + super.dispose(); + } + + void _onFocus() => setState(() {}); + + @override + Widget build(BuildContext context) { + final focused = widget.focusNode.hasFocus; + final filled = widget.controller.text.isNotEmpty; + + return AnimatedContainer( + duration: DmMotion.fast, + curve: DmMotion.ease, + height: 60, + decoration: BoxDecoration( + color: filled && !focused ? DmColors.brandSoft : DmColors.surface, + borderRadius: DmRadius.all(DmRadius.md), + border: Border.all( + color: focused + ? DmColors.brand + : filled + ? DmColors.brandLine + : DmColors.border, + ), + // The focus ring, exactly as the fields draw it — this is the same + // control, one digit wide. + boxShadow: focused + ? const [ + BoxShadow( + color: DmColors.brandSoft, + spreadRadius: 3, + blurRadius: 0, + ), + ] + : null, + ), + child: Center( + child: TextField( + controller: widget.controller, + focusNode: widget.focusNode, + onChanged: widget.onChanged, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + cursorColor: DmColors.brand, + cursorWidth: 2, + showCursor: true, + style: DmText.otp, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + decoration: const InputDecoration( + counterText: '', + isDense: true, + filled: false, + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + ), + ), + ); + } +} diff --git a/lib/ui/screens/auth/signup_screen.dart b/lib/ui/screens/auth/signup_screen.dart new file mode 100644 index 0000000..69a910e --- /dev/null +++ b/lib/ui/screens/auth/signup_screen.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../data/doormile_api.dart'; +import '../../../state/app_scope.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/feedback.dart'; +import '../../widgets/inputs.dart'; +import '../../widgets/pieces.dart'; +import 'auth_scaffold.dart'; +import 'otp_screen.dart'; + +/// Create an account — name and phone, with email optional. +/// +/// The same OTP screen verifies both sign in and sign up; the name is carried +/// through so the new account is created with it. +class SignUpScreen extends StatefulWidget { + const SignUpScreen({super.key}); + + @override + State createState() => _SignUpScreenState(); +} + +class _SignUpScreenState extends State { + final _name = TextEditingController(); + final _phone = TextEditingController(); + final _email = TextEditingController(); + bool _sending = false; + + @override + void initState() { + super.initState(); + for (final c in [_name, _phone, _email]) { + c.addListener(() => setState(() {})); + } + } + + @override + void dispose() { + for (final c in [_name, _phone, _email]) { + c.dispose(); + } + super.dispose(); + } + + bool get _valid => + _name.text.trim().length >= 2 && _phone.text.trim().length >= 10; + + Future _continue() async { + setState(() => _sending = true); + final phone = '+91 ${_phone.text.trim()}'; + try { + final challenge = await AppScope.read(context).signUp( + name: _name.text.trim(), + phone: phone, + email: _email.text.trim().isEmpty ? null : _email.text.trim(), + ); + if (!mounted) return; + setState(() => _sending = false); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => OtpScreen( + identifier: phone, + name: _name.text.trim(), + challenge: challenge, + ), + ), + ); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _sending = false); + DmToast.show(context, e.message); + } + } + + @override + Widget build(BuildContext context) { + return AuthScaffold( + onBack: () => Navigator.of(context).maybePop(), + // Same entrance as sign in: the headline sits on the brand field at + // display size and the sheet starts straight into the form. Sign in and + // sign up are one doorway with two doors, so they cannot be laid out + // differently — a customer who bounces between them should see the + // screen change its words, not its shape. + heroTitle: 'Create your account.', + heroSub: 'A few details and you can book your first pickup.', + // Pinned for the same reason as sign in: a three-field form plus the + // keyboard leaves no room, and the CTA must not be the thing that loses. + footer: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DmButton( + label: 'Continue', + busy: _sending, + busyLabel: 'Sending code…', + onPressed: _valid ? _continue : null, + ), + const SizedBox(height: 10), + AuthSwitchLink( + question: 'Already have an account?', + action: 'Sign in', + onTap: () => Navigator.of(context).maybePop(), + ), + const SizedBox(height: 6), + const AuthLegal(), + ], + ), + children: [ + DmTextField( + label: 'Full name', + controller: _name, + hint: 'Joe Oommen', + keyboardType: TextInputType.name, + textInputAction: TextInputAction.next, + ), + DmTextField( + label: 'Phone number', + controller: _phone, + hint: '98765 43210', + prefix: '+91', + keyboardType: TextInputType.phone, + digitsOnly: true, + maxLength: 10, + textInputAction: TextInputAction.next, + ), + DmTextField( + label: 'Email', + optional: true, + controller: _email, + hint: 'you@example.com', + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.done, + onSubmitted: (_) => _valid ? _continue() : null, + ), + // The same reassurance sign in carries, in the same wash card, under + // the field it is about. + const DmInfoBanner( + icon: LucideIcons.shieldCheck, + title: 'We verify every number', + message: "A 4-digit code confirms it's you and keeps your parcels " + 'secure.', + ), + ], + ); + } +} diff --git a/lib/ui/screens/booking/booking_routes.dart b/lib/ui/screens/booking/booking_routes.dart new file mode 100644 index 0000000..3148764 --- /dev/null +++ b/lib/ui/screens/booking/booking_routes.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +/// Named steps in the booking flow. +/// +/// Naming the routes lets Review's "Edit" actions pop straight back to the +/// step being corrected instead of stacking a second copy of it. +class BookingRoutes { + BookingRoutes._(); + + static const pickup = 'booking/pickup'; + static const destination = 'booking/destination'; + static const slot = 'booking/slot'; + static const review = 'booking/review'; + static const confirmed = 'booking/confirmed'; + static const tracking = 'tracking'; +} + +/// A page route tagged with its step name. +Route bookingRoute(String name, Widget Function(BuildContext) builder) { + return MaterialPageRoute( + settings: RouteSettings(name: name), + builder: builder, + ); +} + +/// Returns to an earlier step if it is still on the stack, otherwise pushes a +/// fresh copy of it. The navigator is captured up front because popping +/// unmounts the calling screen. +void editStep( + BuildContext context, + String name, + Widget Function(BuildContext) builder, +) { + final navigator = Navigator.of(context); + var found = false; + navigator.popUntil((route) { + if (route.settings.name == name) found = true; + return found || route.isFirst; + }); + if (!found) { + navigator.push(bookingRoute(name, builder)); + } +} diff --git a/lib/ui/screens/booking/confirmed_screen.dart b/lib/ui/screens/booking/confirmed_screen.dart new file mode 100644 index 0000000..b01b032 --- /dev/null +++ b/lib/ui/screens/booking/confirmed_screen.dart @@ -0,0 +1,263 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../data/app_config.dart'; +import '../../../state/app_scope.dart'; +import '../../tokens.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/cards.dart'; +import '../../widgets/chrome.dart'; +import '../../widgets/misc.dart'; +import '../../widgets/pieces.dart'; +import '../../widgets/route_rail.dart'; +import '../tracking_screen.dart'; +import 'booking_routes.dart'; + +/// Booking confirmed. +/// +/// A booking reference and a reserved window — not a shipment. The tracking +/// number appears later, once the Miler completes pickup. +/// +/// ── What this screen stopped doing ── +/// +/// It opened with a green tick in a circle, then a map, then a reference, then +/// a summary card, then two banners: five surfaces to say one thing. The tick +/// was also the only green in the product, which made the app's happiest moment +/// look like a system dialog rather than like Doormile. +/// +/// It is one crimson card now — the payoff — followed by the reference, the +/// route as a thread, and the live search for a Miler as a row on that same +/// card. The map is gone: the customer chose this route two screens ago and a +/// second picture of it says nothing new while the parcel has not moved. +class ConfirmedScreen extends StatefulWidget { + const ConfirmedScreen({super.key}); + + @override + State createState() => _ConfirmedScreenState(); +} + +class _ConfirmedScreenState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _pop = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 520), + )..forward(); + + @override + void dispose() { + _pop.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + final booking = app.trackedBooking; + final slot = booking == null ? null : app.slotById(booking.slotId); + final packages = booking?.totalPackages ?? 1; + + return Scaffold( + backgroundColor: DmColors.canvas, + body: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 22, DmSpace.pad, 24), + children: [ + DmCard( + tone: DmCardTone.fill, + children: [ + DmCardCell( + padding: const EdgeInsets.fromLTRB(18, 22, 18, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ScaleTransition( + scale: CurvedAnimation( + parent: _pop, + curve: Curves.easeOutBack, + ), + child: Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: DmColors.onBrandRaise, + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.check, + size: 24, + color: Colors.white, + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Pickup booked', + style: DmText.display.copyWith(color: Colors.white), + ), + const SizedBox(height: 8), + Text( + booking == null + ? "We've reserved your slot." + : "We've reserved your slot. A nearby Miler is being " + 'assigned to collect ' + '${packages == 1 ? 'your package' : 'your packages'}.', + style: DmText.lede.copyWith(color: DmColors.onBrand2), + ), + ], + ), + ), + ], + ), + if (booking != null) ...[ + const SizedBox(height: 10), + DmCard( + tone: DmCardTone.wash, + children: [ + DmCardCell( + padding: const EdgeInsets.fromLTRB(16, 13, 8, 13), + child: DmCopyRow( + label: 'Booking reference', + value: booking.reference, + onWash: true, + ), + ), + ], + ), + const SizedBox(height: 10), + DmCard( + children: [ + DmCardCell( + child: DmRouteRail( + pickup: booking.pickup.title, + pickupNote: booking.pickup.sub, + drops: [ + for (final group in booking.destinations) + DmRailStop( + label: group.destination.label, + note: booking.isMultiDestination + ? null + : group.details.exactAddress, + trailing: + '${group.packageCount} ' + '${group.packageCount == 1 ? 'package' : 'packages'}', + ), + ], + ), + ), + DmCardCell( + background: DmColors.brandSoft, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + const Icon( + LucideIcons.calendarCheck, + size: 15, + color: DmColors.brand, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + // The day rides with the label so the window itself + // stays on one line. "Scheduled window" against + // "Today · 2:00 – 4:00 PM" broke across two, and + // wrapped a time range mid-range. + slot == null + ? 'Scheduled window' + : 'Scheduled ${slot.day.toLowerCase()}', + style: DmText.small.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 10), + Flexible( + child: Text( + slot?.window ?? '—', + textAlign: TextAlign.right, + style: DmText.cardTitle.copyWith( + color: DmColors.brandPress, + ), + ), + ), + ], + ), + ), + // The search is live from the moment this screen opens, so it + // belongs on the card rather than in a banner underneath it. + DmCardCell( + background: DmColors.brandSoft, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + const DmPill('Searching', tone: DmPillTone.live), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Finding the nearest Miler', + style: DmText.small.copyWith( + color: DmColors.brand, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox( + width: 15, + height: 15, + child: CircularProgressIndicator( + strokeWidth: 2, + strokeCap: StrokeCap.round, + valueColor: AlwaysStoppedAnimation(DmColors.brand), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + // The one screen where a fake booking is most convincing, so it + // is the one screen that has to admit it. + if (AppConfig.useDevData) + const DmInfoBanner( + icon: LucideIcons.wifiOff, + tone: DmTagTone.warn, + title: 'Offline dev data — nothing was sent', + message: 'This build is not talking to a server, so this ' + 'booking exists only on this phone and will not appear ' + 'in the admin console.', + ) + else + const DmInfoBanner( + icon: LucideIcons.package, + tone: DmTagTone.neutral, + title: 'Keep the parcel ready', + message: 'Sealed and labelled with the reference above.', + ), + ], + ], + ), + ), + bottomNavigationBar: DmFooter( + children: [ + DmButton( + label: 'Track pickup', + icon: LucideIcons.arrowRight, + onPressed: () => Navigator.of(context).push( + bookingRoute(BookingRoutes.tracking, (_) => const TrackingScreen()), + ), + ), + DmButton( + label: 'Back to home', + kind: DmButtonKind.ghost, + onPressed: () => Navigator.of(context).popUntil((route) => route.isFirst), + ), + ], + ), + ); + } +} diff --git a/lib/ui/screens/booking/destination_screen.dart b/lib/ui/screens/booking/destination_screen.dart new file mode 100644 index 0000000..abafe6b --- /dev/null +++ b/lib/ui/screens/booking/destination_screen.dart @@ -0,0 +1,406 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../data/models.dart'; +import '../../../state/app_scope.dart'; +import '../../tokens.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/cards.dart'; +import '../../widgets/chrome.dart'; +import '../../widgets/pieces.dart'; +import '../../widgets/states.dart'; +import 'booking_routes.dart'; +import 'slot_screen.dart'; + +/// Step 2 — where the packages are going. +/// +/// Opens as a single destination, which is the common case: pick a state, pick +/// a district, done. Package count is never asked for — it keeps its model +/// default of 1 — and sending to a second place is a secondary action that only +/// appears once the first one is settled. A customer with one package to one +/// district never sees the multi-destination machinery. +class DestinationScreen extends StatefulWidget { + const DestinationScreen({super.key}); + + @override + State createState() => _DestinationScreenState(); +} + +class _DestinationScreenState extends State { + /// Which block is open for editing. Null when every block is settled, so + /// coming back from Review shows the chosen places rather than re-opening + /// the picker. + int? _editing; + + @override + void initState() { + super.initState(); + final groups = AppScope.read(context).draftDestinations; + final firstIncomplete = groups.indexWhere((g) => !g.isComplete); + _editing = firstIncomplete == -1 ? null : firstIncomplete; + } + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + final groups = app.draftDestinations; + final limits = app.limits; + + return Scaffold( + backgroundColor: DmColors.canvas, + appBar: const DmTopBar(title: 'Schedule pickup'), + body: Column( + children: [ + const DmStepHeader(step: 2, total: 4, label: 'Destination'), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB( + DmSpace.pad, + 0, + DmSpace.pad, + 28, + ), + children: [ + // The question is the instruction. The line under it said + // "Choose from the areas we currently serve", which is what the + // list below already demonstrates by only containing those + // areas — a caption for a control that captions itself. + Text('Where are you sending it?', style: DmText.title), + const SizedBox(height: 20), + + for (var i = 0; i < groups.length; i++) ...[ + _DestinationBlock( + index: i, + group: groups[i], + editing: _editing == i || !groups[i].isComplete, + showRemove: groups.length > 1, + onEdit: () => setState(() => _editing = i), + onSettled: () => setState(() => _editing = null), + onRemove: () { + app.removeDestination(i); + setState(() => _editing = null); + }, + ), + const SizedBox(height: 12), + ], + + // Offered only where the backend allows more than one place. + // While `maxDestinations` is 1 there is nothing to explain and + // nothing to disable — a control that can never be used is + // better absent than permanently greyed. + if (app.allowsMultipleDestinations) + _AddDestination( + enabled: app.canAddDestination, + reason: _capReason(app, limits), + onTap: () { + if (app.addDestination()) { + setState( + () => _editing = app.draftDestinations.length - 1, + ); + } + }, + ), + ], + ), + ), + ], + ), + bottomNavigationBar: DmFooter( + edge: true, + children: [ + DmButton( + label: 'Next', + icon: LucideIcons.arrowRight, + onPressed: app.draftReady + ? () => Navigator.of(context).push( + bookingRoute( + BookingRoutes.slot, + (_) => const SlotScreen(), + ), + ) + : null, + ), + ], + ), + ); + } + + /// Friendly, non-alarming explanation when a cap blocks another place. + String? _capReason(dynamic app, BookingLimits limits) { + if (app.canAddDestination as bool) return null; + // Mid-selection is not a problem to explain — the action simply is not + // offered yet. + if (!(app.draftReady as bool)) return null; + if ((app.draftDestinations as List).length >= limits.maxDestinations) { + return 'Up to ${limits.maxDestinations} places in one pickup. ' + 'Book another pickup for more.'; + } + return 'Up to ${limits.maxPackages} packages in one pickup.'; + } +} + +/// One place: state, district, how many packages, optional details. +class _DestinationBlock extends StatelessWidget { + const _DestinationBlock({ + required this.index, + required this.group, + required this.editing, + required this.showRemove, + required this.onEdit, + required this.onSettled, + required this.onRemove, + }); + + final int index; + final DestinationGroup group; + final bool editing; + final bool showRemove; + final VoidCallback onEdit; + final VoidCallback onSettled; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + return AnimatedSize( + duration: DmMotion.base, + curve: DmMotion.ease, + alignment: Alignment.topCenter, + child: editing + ? _Picker(index: index, group: group, onSettled: onSettled) + : _Settled( + index: index, + group: group, + showRemove: showRemove, + onEdit: onEdit, + onRemove: onRemove, + ), + ); + } +} + +/// Choose the state, then the districts open beneath it. +class _Picker extends StatelessWidget { + const _Picker({ + required this.index, + required this.group, + required this.onSettled, + }); + + final int index; + final DestinationGroup group; + final VoidCallback onSettled; + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + final destination = group.destination; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const DmMicroHead('Sending to', top: 0, bottom: 10), + DmAsyncList( + skeletonRows: 1, + load: ({bool refresh = false}) => app.loadStates(refresh: refresh), + emptyTitle: 'No service areas available', + emptyMessage: "We're not accepting new pickups right now. Please check back shortly.", + builder: (context, states) => SizedBox( + height: 42, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.zero, + itemCount: states.length, + separatorBuilder: (_, _) => const SizedBox(width: 10), + itemBuilder: (context, i) => DmChoiceChip( + label: states[i].name, + selected: destination.stateCode == states[i].code, + onTap: () => app.selectStateFor(index, states[i]), + ), + ), + ), + ), + if (destination.stateCode != null) ...[ + const SizedBox(height: 18), + const DmMicroHead('District', top: 0, bottom: 10), + DmAsyncList( + key: ValueKey('districts-${destination.stateCode}'), + skeletonRows: 3, + load: ({bool refresh = false}) => + app.loadDistricts(destination.stateCode!, refresh: refresh), + emptyTitle: 'No districts open here yet', + emptyMessage: + "We're not delivering inside ${destination.stateName} right now. " + 'Pick another state above.', + emptyActionLabel: 'Got it', + onEmptyAction: () {}, + builder: (context, districts) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: districts.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + // The cell grows with the reader's text size rather than + // clipping the district name. + // One per row as soon as the reader's text grows, so the + // place name always has room. + crossAxisCount: + MediaQuery.textScalerOf(context).scale(1) > 1.15 + ? 1 + : 2, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + mainAxisExtent: MediaQuery.textScalerOf(context).scale(70), + ), + itemBuilder: (context, i) { + final d = districts[i]; + return DmGridTile( + // Just the place. The delivery promise appears on the + // settled card, where it actually informs a decision. + label: d.name, + selected: destination.districtCode == d.code, + onTap: () { + app.selectDistrictFor(index, d); + onSettled(); + }, + ); + }, + ), + // Places we are opening are named once, quietly — never as a + // grid of tiles the customer cannot use. + if (app.upcomingDistricts(destination.stateCode!).isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12, left: 2, right: 2), + child: Text( + 'Coming soon: ' + '${app.upcomingDistricts(destination.stateCode!).join(', ')}', + style: DmText.small.copyWith(fontSize: 12.5), + ), + ), + ], + ), + ), + ], + ], + ); + } +} + +/// A chosen place, collapsed to a card with its package count. +class _Settled extends StatelessWidget { + const _Settled({ + required this.index, + required this.group, + required this.showRemove, + required this.onEdit, + required this.onRemove, + }); + + final int index; + final DestinationGroup group; + final bool showRemove; + final VoidCallback onEdit; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + return DmCard( + tone: DmCardTone.wash, + children: [ + DmCardRow( + tone: DmCardTone.wash, + onTap: onEdit, + leading: const DmTile( + icon: LucideIcons.mapPin, + tone: DmTileTone.wash, + ), + // Numbered only when there is more than one to tell apart. + eyebrow: showRemove ? 'Destination ${index + 1}' : 'Sending to', + title: group.destination.label, + subtitle: group.district?.promise, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + DmTextAction(label: 'Change', onPressed: onEdit), + if (showRemove) + IconButton( + onPressed: onRemove, + icon: const Icon(LucideIcons.x, size: 17), + color: DmColors.ink3, + tooltip: 'Remove this destination', + visualDensity: VisualDensity.compact, + ), + ], + ), + ), + // ── Two rows removed: package count, and exact delivery details ── + // + // A destination block was a card containing a divider, a stepper in + // its own row, another divider, and a row that opened a bottom sheet + // for optional detail — four decisions stacked under an address the + // customer had already given. Almost every booking is one package to + // one door, so the common path was paying for two controls it never + // touched. + // + // `packageCount` keeps its model default and the read-only screens + // still show it; what is gone is asking. The delivery-details sheet + // is deleted outright — see `sheets/`. + ], + ); + } +} + +/// The quiet secondary action that unlocks multi-destination pickups. +class _AddDestination extends StatelessWidget { + const _AddDestination({ + required this.enabled, + required this.reason, + required this.onTap, + }); + + final bool enabled; + final String? reason; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + if (!enabled) { + return reason == null + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: Text(reason!, style: DmText.small), + ); + } + + return InkWell( + onTap: onTap, + borderRadius: DmRadius.all(DmRadius.xs), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon( + LucideIcons.circlePlus, + size: 19, + color: DmColors.brand, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + 'Send to another place', + style: DmText.label.copyWith( + fontSize: 15, + color: DmColors.brand, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/screens/booking/pickup_location_screen.dart b/lib/ui/screens/booking/pickup_location_screen.dart new file mode 100644 index 0000000..0911d2c --- /dev/null +++ b/lib/ui/screens/booking/pickup_location_screen.dart @@ -0,0 +1,324 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../data/location_service.dart'; +import '../../../state/app_scope.dart'; +import '../../../state/app_state.dart'; +import '../../tokens.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/cards.dart'; +import '../../widgets/chrome.dart'; +import '../../widgets/feedback.dart'; +import '../../widgets/map_panel.dart'; +import '../../widgets/pieces.dart'; +import '../sheets/place_search_sheet.dart'; +import 'booking_routes.dart'; +import 'destination_screen.dart'; + +/// Step 1 — where we collect from. +/// +/// One card: a real map on top with the pin locked to its centre, and the +/// address it resolves to in the box directly underneath. Drag the map, the +/// address follows. Nothing is typed unless the customer wants to. +class PickupLocationScreen extends StatefulWidget { + const PickupLocationScreen({super.key}); + + @override + State createState() => _PickupLocationScreenState(); +} + +class _PickupLocationScreenState extends State { + /// True between the first touch on the map and the address coming back. + bool _pinMoving = false; + + @override + void initState() { + super.initState(); + // Arriving without a pin — ask the device where we are. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final app = AppScope.read(context); + if (app.pickup == null && !app.resolvingPickup) { + app.detectPickupLocation(); + } + }); + } + + Future _search(AppState app) async { + final picked = await showPlaceSearchSheet(context); + if (picked == null || !mounted) return; + app.setPickup(picked); + setState(() => _pinMoving = false); + DmToast.show(context, 'Pickup location updated'); + } + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + final place = app.pickup; + final resolving = app.resolvingPickup || _pinMoving; + + return Scaffold( + backgroundColor: DmColors.canvas, + appBar: const DmTopBar(title: 'Schedule pickup'), + body: Column( + children: [ + const DmStepHeader(step: 1, total: 4, label: 'Pickup point'), + const Padding( + padding: EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 0), + child: _Intro(), + ), + const SizedBox(height: 16), + + // The map and the address it resolves to are one object, so they get + // one card. Not a ListView: the map must win vertical drags. + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB( + DmSpace.pad, + 0, + DmSpace.pad, + 14, + ), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: DmColors.surface, + border: Border.all(color: DmColors.border), + borderRadius: DmRadius.all(DmRadius.md), + ), + child: Column( + children: [ + Expanded( + child: DmMapPanel( + lat: app.mapLat, + lng: app.mapLng, + deviceLat: app.deviceLat, + deviceLng: app.deviceLng, + deviceAccuracy: app.deviceAccuracy, + loading: resolving, + borderRadius: BorderRadius.zero, + onLocate: () { + setState(() => _pinMoving = false); + app.detectPickupLocation(); + }, + onPinMoveStart: () { + if (!_pinMoving) setState(() => _pinMoving = true); + }, + onPinMoved: (lat, lng) { + setState(() => _pinMoving = false); + app.setPickupFromPin(lat, lng); + }, + ), + ), + _AddressBox( + title: place?.title, + sub: place?.sub, + resolving: resolving, + onChange: () => _search(app), + ), + ], + ), + ), + ), + ), + + if (app.locationDenial != null) + Padding( + padding: const EdgeInsets.fromLTRB( + DmSpace.pad, + 0, + DmSpace.pad, + 12, + ), + child: _LocationNotice( + denial: app.locationDenial!, + onSettings: app.openLocationSettings, + onSearch: () => _search(app), + ), + ), + ], + ), + bottomNavigationBar: DmFooter( + edge: true, + children: [ + DmButton( + label: 'Next', + icon: LucideIcons.arrowRight, + // Gated on the pin, not just the address: a booking without + // coordinates is one the Miler cannot be routed to. + onPressed: !app.hasPickupPoint || resolving + ? null + : () { + app.draftPickup = place; + Navigator.of(context).push( + bookingRoute( + BookingRoutes.destination, + (_) => const DestinationScreen(), + ), + ); + }, + ), + ], + ), + ); + } +} + +class _Intro extends StatelessWidget { + const _Intro(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Where should we pick up?', style: DmText.title), + const SizedBox(height: 8), + Text('Drag the map to move the pin.', style: DmText.lede), + ], + ); + } +} + +/// The address under the map. It is the same card, so a hairline separates +/// them rather than a gap. +class _AddressBox extends StatelessWidget { + const _AddressBox({ + required this.title, + required this.sub, + required this.resolving, + required this.onChange, + }); + + final String? title; + final String? sub; + final bool resolving; + final VoidCallback onChange; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(14, 13, 10, 14), + decoration: const BoxDecoration( + color: DmColors.brandSoft, + border: Border(top: BorderSide(color: DmColors.border)), + ), + child: Row( + children: [ + // The resolved address is the map's answer, so it takes the wash: it + // is brand-owned context, not a field waiting to be filled. + const DmTile(icon: LucideIcons.mapPin, tone: DmTileTone.wash), + const SizedBox(width: 12), + Expanded( + child: AnimatedSwitcher( + duration: DmMotion.base, + child: resolving + ? const _ResolvingLines(key: ValueKey('resolving')) + : Column( + key: ValueKey('$title|$sub'), + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title ?? 'Locating you…', + key: const ValueKey('pickupAddressTitle'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.cardTitle, + ), + const SizedBox(height: 3), + Text( + sub ?? 'Please wait a moment', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.small.copyWith(color: DmColors.brandPress), + ), + ], + ), + ), + ), + DmTextAction(label: 'Change', onPressed: onChange), + ], + ), + ); + } +} + +/// What the address box shows while the pin is moving: the shape of an +/// address, so the card never collapses or jumps. +class _ResolvingLines extends StatelessWidget { + const _ResolvingLines({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Getting address…', + style: DmText.cardTitle.copyWith(color: DmColors.ink3), + ), + const SizedBox(height: 6), + Container( + width: 148, + height: 9, + decoration: BoxDecoration( + color: DmColors.brandLine, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ); + } +} + +/// Shown only when the device refused us a position. Says what happened and +/// offers the way out, rather than leaving the pin sitting on a default. +class _LocationNotice extends StatelessWidget { + const _LocationNotice({ + required this.denial, + required this.onSettings, + required this.onSearch, + }); + + final LocationDenial denial; + final VoidCallback onSettings; + final VoidCallback onSearch; + + @override + Widget build(BuildContext context) { + final settings = denial == LocationDenial.deniedForever; + return Container( + padding: const EdgeInsets.fromLTRB(14, 12, 12, 12), + decoration: BoxDecoration( + color: DmColors.surfaceAlt, + border: Border.all(color: DmColors.border), + borderRadius: DmRadius.all(DmRadius.md), + ), + child: Row( + children: [ + const Icon( + LucideIcons.mapPinOff, + size: 18, + color: DmColors.ink2, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + LocationFix.denied(denial).message, + style: DmText.small.copyWith(color: DmColors.ink2), + ), + ), + const SizedBox(width: 8), + DmChipButton( + label: settings ? 'Settings' : 'Search', + onPressed: settings ? onSettings : onSearch, + ), + ], + ), + ); + } +} diff --git a/lib/ui/screens/booking/review_screen.dart b/lib/ui/screens/booking/review_screen.dart new file mode 100644 index 0000000..89c8311 --- /dev/null +++ b/lib/ui/screens/booking/review_screen.dart @@ -0,0 +1,377 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter/services.dart'; + +import '../../../data/doormile_api.dart'; +import '../../../data/models.dart'; +import '../../../state/app_scope.dart'; +import '../../tokens.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/cards.dart'; +import '../../widgets/chrome.dart'; +import '../../widgets/feedback.dart'; +import '../../widgets/pieces.dart'; +import '../../widgets/route_map.dart'; +import '../../widgets/route_rail.dart'; +import '../../widgets/summary.dart'; +import '../sheets/place_search_sheet.dart'; +import 'booking_routes.dart'; +import 'confirmed_screen.dart'; +import 'destination_screen.dart'; +import 'slot_screen.dart'; + +/// Step 4 — review. +/// +/// Reads the same whether the pickup carries one package to one district or +/// six across three. Nothing is charged here: the fare is indicative until the +/// Miler verifies the packages at the door. +class ReviewScreen extends StatefulWidget { + const ReviewScreen({super.key}); + + @override + State createState() => _ReviewScreenState(); +} + +class _ReviewScreenState extends State { + bool _busy = false; + + Future _confirm() async { + setState(() => _busy = true); + final app = AppScope.read(context); + final navigator = Navigator.of(context); + try { + await app.confirmBooking(); + unawaited(HapticFeedback.mediumImpact()); + navigator.pushAndRemoveUntil( + bookingRoute(BookingRoutes.confirmed, (_) => const ConfirmedScreen()), + (route) => route.isFirst, + ); + } on ApiException catch (e) { + if (!mounted) return; + setState(() => _busy = false); + DmToast.show(context, e.message); + // A slot that filled up, or whose window passed while this screen was + // open, is not something the customer can fix from Review — the only + // move is to pick again. `confirmBooking` has already dropped the stale + // list, so going back re-reads it. + if (e.needsFreshSlots) { + navigator.popUntil( + (route) => route.settings.name == BookingRoutes.slot || route.isFirst, + ); + } + } + } + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + final pickup = app.draftPickup ?? app.pickup; + final slot = app.slotById(app.draftSlotId); + final groups = app.draftDestinations; + final multi = groups.length > 1; + final packages = app.draftTotalPackages; + final fare = app.draftFare; + + return Scaffold( + backgroundColor: DmColors.canvas, + appBar: const DmTopBar(title: 'Schedule pickup'), + body: Column( + children: [ + const DmStepHeader(step: 4, total: 4, label: 'Review'), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28), + children: [ + // "Pay nothing now" belongs beside the amount, where the + // summary already states it — not as a paragraph under the + // heading that has to be read before the thing it describes. + Text('Review pickup details', style: DmText.title), + const SizedBox(height: 18), + DmCard( + children: [ + DmRouteMap( + height: 178, + radius: 0, + pickup: pickup, + destination: groups.first.hub, + pickupLabel: pickup?.title, + destinationLabel: multi + ? '${groups.length} destinations' + : groups.first.destination.districtName, + badge: _MapBadge( + icon: multi ? LucideIcons.route : LucideIcons.arrowRight, + label: multi + ? '${groups.length} destinations' + : 'Door to door', + ), + ), + ], + ), + const SizedBox(height: 10), + DmSummaryCard( + children: [ + // One journey, one rail: pickup connected to everywhere it + // is going, exactly as an order card reads. + _RouteSection( + pickup: pickup, + groups: groups, + onEditPickup: () async { + final picked = await showPlaceSearchSheet(context); + if (picked != null && context.mounted) { + app.setPickup(picked); + app.draftPickup = picked; + } + }, + onChangeDestinations: () => editStep( + context, + BookingRoutes.destination, + (_) => const DestinationScreen(), + ), + ), + DmSummaryRow( + label: 'Pickup time', + value: slot?.label ?? '—', + actionLabel: 'Change', + onAction: () => editStep( + context, + BookingRoutes.slot, + (_) => const SlotScreen(), + ), + ), + ], + ), + if (multi) ...[ + const SizedBox(height: 10), + Row( + children: [ + const Icon( + LucideIcons.package, + size: 17, + color: DmColors.ink3, + ), + const SizedBox(width: 8), + Flexible( + child: Text( + '$packages packages · ${groups.length} destinations', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.label.copyWith(fontSize: 14), + ), + ), + const SizedBox(width: 10), + Flexible( + child: Text( + 'One Miler visit', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: DmText.small, + ), + ), + ], + ), + ], + const SizedBox(height: 10), + DmNote( + child: Text.rich( + TextSpan( + style: DmText.small.copyWith(color: DmColors.ink2, height: 1.5), + children: [ + const TextSpan(text: 'This books '), + const TextSpan( + text: 'one Miler visit', + style: TextStyle( + fontWeight: FontWeight.w700, + color: DmColors.ink, + ), + ), + TextSpan( + text: '. Tracking is created once your packages are ' + 'collected, and cancelling is free until then. ' + 'The amount below covers ', + ), + TextSpan( + text: '$packages ' + '${packages == 1 ? 'package' : 'packages'} and is ' + 'confirmed after the Miler verifies them at your ' + 'door.', + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + bottomNavigationBar: DmFooter( + edge: true, + children: [ + // The price sits directly above the button that agrees to it. It + // used to live in a card halfway up the page, which meant the last + // thing on screen before "Book Pickup" could easily be a note about + // cancellation rather than the amount. + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Estimated total', style: DmText.small), + // The method is its own line: appended to the label it + // made one long string that truncated mid-word, and the + // customer reads the two facts separately anyway. + if (fare?.paymentMethod != null) + Text( + fare!.paymentMethod!, + style: DmText.tiny.copyWith(fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + fare?.range ?? 'Calculating…', + style: DmText.monoLg.copyWith(fontSize: 20, color: DmColors.brand), + ), + ], + ), + ), + DmButton( + label: 'Book pickup', + icon: LucideIcons.arrowRight, + busy: _busy, + busyLabel: 'Booking…', + onPressed: app.draftReady ? _confirm : null, + ), + DmButton( + label: 'Edit details', + kind: DmButtonKind.ghost, + onPressed: _busy ? null : () => Navigator.of(context).maybePop(), + ), + ], + ), + ); + } +} + +/// Pickup and every destination on one rail, with the two edit actions. +class _RouteSection extends StatelessWidget { + const _RouteSection({ + required this.pickup, + required this.groups, + required this.onEditPickup, + required this.onChangeDestinations, + }); + + final Place? pickup; + final List groups; + final VoidCallback onEditPickup; + final VoidCallback onChangeDestinations; + + bool get _anyMissingDetails => groups.any( + (g) => g.details.exactAddress == null && g.details.recipient == null, + ); + + @override + Widget build(BuildContext context) { + final multi = groups.length > 1; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 15, 16, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DmRouteRail( + pickupEyebrow: 'Pickup', + pickup: pickup?.title ?? '—', + pickupNote: pickup?.sub, + pickupTrailing: DmTextAction( + label: 'Edit', + onPressed: onEditPickup, + minHeight: 32, + ), + dropsEyebrow: 'Sending to', + drops: [ + for (final group in groups) + DmRailStop( + label: group.destination.label, + trailing: + '${group.packageCount} ' + '${group.packageCount == 1 ? 'package' : 'packages'}', + note: multi + ? null + : (group.details.exactAddress ?? + (group.details.recipient == null + ? 'Not added — the Miler can confirm this at pickup.' + : 'Recipient · ${group.details.recipient}')), + ), + ], + ), + // With several places, say it once rather than per line. + if (multi && _anyMissingDetails) + Padding( + padding: const EdgeInsets.only(top: 8, left: 30), + child: Text( + 'Exact addresses not added — the Miler can confirm these at pickup.', + style: DmText.small.copyWith(fontSize: 12.5), + ), + ), + Align( + alignment: Alignment.centerRight, + child: DmTextAction( + label: multi ? 'Change destinations' : 'Change destination', + onPressed: onChangeDestinations, + minHeight: 36, + ), + ), + ], + ), + ); + } +} + +class _MapBadge extends StatelessWidget { + const _MapBadge({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(10, 7, 12, 7), + decoration: BoxDecoration( + color: DmColors.surface, + border: Border.all(color: DmColors.border), + borderRadius: DmRadius.all(6), + boxShadow: DmShadow.float, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: DmColors.brand), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.label.copyWith(fontSize: 12, color: DmColors.ink), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/screens/booking/slot_screen.dart b/lib/ui/screens/booking/slot_screen.dart new file mode 100644 index 0000000..b4321ac --- /dev/null +++ b/lib/ui/screens/booking/slot_screen.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../data/models.dart'; +import '../../../state/app_scope.dart'; +import '../../tokens.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/cards.dart'; +import '../../widgets/chrome.dart'; +import '../../widgets/feedback.dart'; +import '../../widgets/option_tile.dart'; +import '../../widgets/pieces.dart'; +import '../../widgets/states.dart'; +import '../sheets/place_search_sheet.dart'; +import 'booking_routes.dart'; +import 'review_screen.dart'; + +/// Step 2 — Pickup timing. +/// +/// Confirms where we are collecting from and which window the customer wants, +/// showing only what the backend still has capacity for. +class SlotScreen extends StatefulWidget { + const SlotScreen({super.key}); + + @override + State createState() => _SlotScreenState(); +} + +class _SlotScreenState extends State { + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + final pickup = app.draftPickup ?? app.pickup; + + return Scaffold( + backgroundColor: DmColors.canvas, + appBar: const DmTopBar(title: 'Schedule pickup'), + body: Column( + children: [ + const DmStepHeader(step: 3, total: 4, label: 'Pickup time'), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28), + children: [ + // Reassurance, not instruction: "A verified Miler arrives at + // your door inside the window you pick" restated the heading, + // the product, and the list of windows underneath it. The + // customer is here to pick a time. + Text('When should we come?', style: DmText.title), + const SizedBox(height: 20), + + // ── Where we are collecting from ── + // + // A wash card, because it is brand-owned context rather than a + // field: the customer already chose this address on the screen + // before, and it is here to be confirmed at a glance, not + // re-entered. Change is the only control on it. + DmCard( + tone: DmCardTone.wash, + children: [ + DmCardRow( + tone: DmCardTone.wash, + leading: const DmTile( + icon: LucideIcons.mapPin, + tone: DmTileTone.wash, + ), + eyebrow: 'Collecting from', + title: pickup?.title ?? 'Locating you…', + subtitle: pickup?.sub, + trailing: DmTextAction( + label: 'Change', + onPressed: () async { + final picked = await showPlaceSearchSheet(context); + if (picked != null && context.mounted) { + app.setPickup(picked); + app.draftPickup = picked; + DmToast.show(context, 'Pickup location updated'); + } + }, + ), + ), + ], + ), + const SizedBox(height: 22), + + // ---- available windows -------------------------------------- + // One eyebrow, not two. "Local time (IST)" sat opposite this + // one explaining a timezone the customer is standing in. + const DmMicroHead('Available windows', top: 0, bottom: 10), + DmAsyncList( + // Re-reads whenever the slot list is invalidated — starting a + // booking, or the server refusing a window that has passed. + reloadToken: app.slotsEpoch, + load: ({bool refresh = false}) => app.loadSlots(refresh: refresh), + emptyIcon: LucideIcons.clock, + emptyTitle: 'No pickup slots left today', + emptyMessage: + 'All windows are full right now. Try again in a little while.', + builder: (context, slots) { + final days = []; + for (final slot in slots) { + if (!days.contains(slot.day)) days.add(slot.day); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final day in days) ...[ + Padding( + padding: const EdgeInsets.only(bottom: 10, top: 4), + child: Text(day, style: DmText.sectionHead), + ), + for (final slot in slots.where((s) => s.day == day)) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _SlotCard( + slot: slot, + selected: app.draftSlotId == slot.id, + onTap: () => app.selectSlot(slot), + ), + ), + const SizedBox(height: 10), + ], + ], + ); + }, + ), + + ], + ), + ), + ], + ), + bottomNavigationBar: DmFooter( + edge: true, + children: [ + DmButton( + label: 'Review booking', + icon: LucideIcons.arrowRight, + onPressed: app.draftSlotId == null + ? null + : () { + Navigator.of(context).push( + bookingRoute( + BookingRoutes.review, + (_) => const ReviewScreen(), + ), + ); + }, + ), + ], + ), + ); + } +} + +/// One window. +/// +/// Selection **fills** here (see [DmOptionTile]): on a list of five windows a +/// tinted card was too quiet to answer "which one did I pick?" at a glance, +/// and this is the last decision before the customer commits to a price. +class _SlotCard extends StatelessWidget { + const _SlotCard({required this.slot, required this.selected, required this.onTap}); + + final PickupSlot slot; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final enabled = slot.available; + + return DmOptionTile( + title: slot.window, + // The time is the thing being chosen, so it is set in the display face + // rather than at row weight. + titleStyle: DmText.display.copyWith(fontSize: 19, height: 1.2), + subtitle: enabled ? slot.caption : (slot.note ?? 'Unavailable'), + selected: selected, + enabled: enabled, + onTap: onTap, + tags: [ + if (slot.tag != null) + DmTag( + slot.tag!, + tone: selected ? DmTagTone.onFill : DmTagTone.brand, + icon: LucideIcons.zap, + ), + if (slot.milersNearby > 0) + DmTag( + '${slot.milersNearby} Milers nearby', + tone: selected ? DmTagTone.onFill : DmTagTone.neutral, + icon: LucideIcons.userRound, + ), + ], + // A full window offers the only thing left to do with it. + trailing: enabled + ? null + : DmTextAction( + label: 'Notify me', + color: DmColors.ink3, + onPressed: () => + DmToast.show(context, "We'll let you know if it frees up"), + ), + ); + } +} diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart new file mode 100644 index 0000000..e2dadae --- /dev/null +++ b/lib/ui/screens/home_screen.dart @@ -0,0 +1,345 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../data/models.dart'; +import '../../state/app_scope.dart'; +import '../format.dart'; +import '../tokens.dart'; +import '../widgets/buttons.dart'; +import '../widgets/cards.dart'; +import '../widgets/chrome.dart'; +import '../widgets/misc.dart'; +import '../widgets/route_rail.dart'; +import 'booking/booking_routes.dart'; +import 'booking/pickup_location_screen.dart'; +import 'shell_screen.dart'; +import 'sheets/place_search_sheet.dart'; +import 'tracking_screen.dart'; + +/// Home — one question, one dominant action, and whatever is live right now. +/// Nothing else competes; history belongs to the Orders tab. +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + final active = app.activeBookings.isEmpty ? null : app.activeBookings.first; + final firstRun = app.orders.isEmpty; + + // The pickup point is already confirmed on this screen, so booking starts + // at the destination — two taps from here to a reserved slot. + void startBooking() { + AppScope.read(context).startBooking(); + Navigator.of(context).push( + bookingRoute( + BookingRoutes.pickup, + (_) => const PickupLocationScreen(), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DmBrandHeader( + eyebrow: 'Welcome, ${_firstName(app.customer?.name)}', + title: firstRun ? 'Send your first parcel' : 'Send a parcel', + action: InkResponse( + onTap: () => ShellScreen.of(context)?.goToTab(2), + radius: 26, + child: DmAvatar(initials: initialsOf(app.customer?.name), onBrand: true), + ), + // The address is the one fact every booking starts from, so it sits + // in the header as a live value the customer can change in place — + // not as the first question of the flow. + child: DmHeaderTrack( + leading: const DmLiveDot(size: 6, color: Colors.white), + trailing: const Icon( + LucideIcons.chevronDown, + size: 16, + color: DmColors.onBrand3, + ), + onTap: () async { + final place = await showPlaceSearchSheet(context); + if (place != null && context.mounted) app.setPickup(place); + }, + child: Text( + app.pickup?.title ?? 'Set a pickup address', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.cardTitle.copyWith(color: Colors.white), + ), + ), + ), + Expanded( + child: RefreshIndicator( + color: DmColors.brand, + backgroundColor: DmColors.surface, + onRefresh: () async { + await Future.wait([app.detectPickupLocation(), app.refreshOrders()]); + }, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 16, DmSpace.pad, 28), + children: [ + _BookCta(onTap: startBooking), + if (active != null) ...[ + const DmMicroHead('Happening now'), + _ActiveCard(booking: active), + ], + if (firstRun) const _HowItWorks(), + ], + ), + ), + ), + ], + ); + } + + static String _firstName(String? name) { + final first = (name ?? '').trim().split(RegExp(r'\s+')).first; + return first.isEmpty ? 'there' : first; + } +} + +/// The screen's one action, as a filled crimson card. +/// +/// It was a 68pt bar reading BOOK in tracked capitals — a word with no object, +/// which on first run told a new customer nothing about what the app does. The +/// card says what will happen ("a Miler collects from your door in a window you +/// choose") and puts the action inside it, so the promise and the button are +/// one object rather than a shout followed by an explanation elsewhere. +class _BookCta extends StatelessWidget { + const _BookCta({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return DmCard( + tone: DmCardTone.fill, + children: [ + DmCardCell( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Book a pickup', + style: DmText.heading.copyWith(fontSize: 19, color: Colors.white), + ), + const SizedBox(height: 4), + Text( + 'A Miler collects from your door in a window you choose.', + style: DmText.small.copyWith(color: DmColors.onBrand2), + ), + const SizedBox(height: 14), + // White on crimson: inside a filled card the primary action has + // to invert, or it disappears into its own background. + _WhiteButton(label: 'Start booking', onTap: onTap), + ], + ), + ), + ], + ); + } +} + +class _WhiteButton extends StatefulWidget { + const _WhiteButton({required this.label, required this.onTap}); + + final String label; + final VoidCallback onTap; + + @override + State<_WhiteButton> createState() => _WhiteButtonState(); +} + +class _WhiteButtonState extends State<_WhiteButton> { + bool _down = false; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: widget.label, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => setState(() => _down = true), + onTapUp: (_) => setState(() => _down = false), + onTapCancel: () => setState(() => _down = false), + onTap: widget.onTap, + child: AnimatedContainer( + duration: DmMotion.fast, + curve: DmMotion.ease, + // A minimum, not a height: at large accessibility text the label + // needs the extra line, and a fixed 46 would clip it. + constraints: const BoxConstraints(minHeight: 46), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: _down ? DmColors.brandSoft : Colors.white, + borderRadius: DmRadius.all(DmRadius.sm), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + widget.label, + textAlign: TextAlign.center, + style: DmText.button.copyWith(fontSize: 15, color: DmColors.brand), + ), + ), + const SizedBox(width: 9), + const Icon(LucideIcons.arrowRight, size: 18, color: DmColors.brand), + ], + ), + ), + ), + ); + } +} + +/// Shown on first run only: three numbered rows that match the booking flow +/// exactly, so the first send has no surprises in it. +class _HowItWorks extends StatelessWidget { + const _HowItWorks(); + + static const _steps = [ + ('Tell us where it goes', 'City, address and recipient'), + ('Pick a two-hour window', 'With live Miler availability'), + ('Hand it over and track', 'Door to hub to doorstep, with proof'), + ]; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const DmMicroHead('How it works'), + DmCard( + children: [ + for (var i = 0; i < _steps.length; i++) + DmCardRow( + leading: DmTile(label: '${i + 1}', tone: DmTileTone.brand), + title: _steps[i].$1, + subtitle: _steps[i].$2, + ), + ], + ), + ], + ); + } +} + +class _ActiveCard extends StatelessWidget { + const _ActiveCard({required this.booking}); + + final Booking booking; + + @override + Widget build(BuildContext context) { + final app = AppScope.read(context); + final slot = app.slotById(booking.slotId); + final beforeHandover = booking.stage.index <= JourneyStage.pickedUp.index; + final windowLabel = slot == null + ? 'Pickup scheduled' + : '${slot.day} · ${slot.window}'; + + final courierLine = switch (booking.stage) { + JourneyStage.booked => 'Matching the nearest Miler', + JourneyStage.assigned when booking.miler != null => + '${booking.miler!.name} will collect your parcel', + JourneyStage.onTheWay when booking.miler != null => + '${booking.miler!.name} is on route' + '${booking.milerDistanceKm == null ? '' : ' · ${booking.milerDistanceKm} km away'}', + JourneyStage.arrived when booking.miler != null => + '${booking.miler!.name} is at your door', + _ => + booking.isMultiDestination + ? 'To ${booking.destinations.length} destinations' + : 'To ${booking.destination.label}', + }; + + return DmCard( + onTap: () { + app.openTracking(booking.reference); + Navigator.of(context).push( + bookingRoute(BookingRoutes.tracking, (_) => const TrackingScreen()), + ); + }, + children: [ + DmCardCell( + background: DmColors.brandSoft, + padding: const EdgeInsets.fromLTRB(14, 11, 14, 11), + child: Row( + children: [ + Flexible(child: DmPill(booking.stage.milestoneLabel)), + const SizedBox(width: 10), + Expanded( + child: Text( + booking.displayId, + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.mono, + ), + ), + ], + ), + ), + DmCardCell( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DmRouteRail( + compact: true, + pickup: booking.pickup.title, + drop: booking.isMultiDestination + ? '${booking.destinations.length} destinations' + : booking.destination.label, + ), + const SizedBox(height: 12), + Text(courierLine, style: DmText.small), + ], + ), + ), + DmCardCell( + padding: const EdgeInsets.fromLTRB(16, 12, 10, 12), + child: Row( + children: [ + Icon( + beforeHandover ? LucideIcons.clock : LucideIcons.truck, + size: 15, + color: DmColors.ink3, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + beforeHandover ? windowLabel : 'On its way', + style: DmText.small, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + DmTextAction( + label: 'Track', + icon: LucideIcons.arrowRight, + onPressed: () { + app.openTracking(booking.reference); + Navigator.of(context).push( + bookingRoute( + BookingRoutes.tracking, + (_) => const TrackingScreen(), + ), + ); + }, + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/ui/screens/order_details_screen.dart b/lib/ui/screens/order_details_screen.dart new file mode 100644 index 0000000..3614f6d --- /dev/null +++ b/lib/ui/screens/order_details_screen.dart @@ -0,0 +1,619 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../data/models.dart'; +import '../../state/app_scope.dart'; +import '../format.dart'; +import '../tokens.dart'; +import '../widgets/buttons.dart'; +import '../widgets/cards.dart'; +import '../widgets/chrome.dart'; +import '../widgets/feedback.dart'; +import '../widgets/milestones.dart'; +import '../widgets/misc.dart'; +import '../widgets/pieces.dart'; +import '../widgets/route_rail.dart'; +import '../widgets/states.dart'; +import '../widgets/summary.dart'; + +/// A finished order, in full: where it went, what happened when, and exactly +/// what it cost. +class OrderDetailsScreen extends StatelessWidget { + const OrderDetailsScreen({super.key, required this.reference}); + + final String reference; + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + final booking = app.byReference(reference); + + if (booking == null) { + return Scaffold( + backgroundColor: DmColors.canvas, + appBar: const DmTopBar(title: 'Order details'), + body: const DmEmptyState( + icon: LucideIcons.package, + title: 'Order not found', + message: 'It may have been removed.', + ), + ); + } + + final cancelled = booking.isCancelled; + final slot = app.slotById(booking.slotId); + final recipient = booking.details.recipient; + final fare = booking.fare; + + return Scaffold( + backgroundColor: DmColors.canvas, + appBar: const DmTopBar(title: 'Order details'), + body: ListView( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 28), + children: [ + // ── The outcome, as the screen's one filled surface ── + // + // Delivered used to be an eyebrow, a pill, a 32pt word and a date, + // all lying flat on the canvas above a stack of white cards. The + // journey is over; this is the moment the app should feel like + // something happened, so it gets the crimson card and everything + // underneath it stays a record. + if (cancelled) + DmCard( + children: [ + DmCardCell( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const DmPill('Cancelled', tone: DmPillTone.off), + const Spacer(), + Flexible( + child: Text( + booking.displayId, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: DmText.mono, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + 'Booking cancelled', + style: DmText.display.copyWith(fontSize: 25), + ), + const SizedBox(height: 6), + Text( + 'Cancelled on ${dateLabel(booking.createdAt)}. ' + 'You were not charged.', + style: DmText.small, + ), + ], + ), + ), + ], + ) + else + DmCard( + tone: DmCardTone.fill, + children: [ + DmCardCell( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 42, + height: 42, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: DmColors.onBrandRaise, + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.check, + size: 22, + color: Colors.white, + ), + ), + const SizedBox(height: 14), + Text( + recipient == null + ? 'Delivered' + : 'Delivered to $recipient', + style: DmText.display.copyWith( + fontSize: 26, + color: Colors.white, + ), + ), + const SizedBox(height: 6), + Text( + '${booking.destination.districtName} · ' + '${dateLabel(booking.deliveredAt ?? booking.createdAt)}', + style: DmText.small.copyWith(color: DmColors.onBrand2), + ), + ], + ), + ), + DmCardCell( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + const Icon( + LucideIcons.package, + size: 15, + color: DmColors.onBrand3, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + booking.packageSummary ?? + '${booking.totalPackages} ' + '${booking.totalPackages == 1 ? 'package' : 'packages'}', + style: DmText.small.copyWith(color: DmColors.onBrand2), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + booking.displayId, + style: DmText.monoSm.copyWith(color: Colors.white), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + + // ---- where it went ------------------------------------------------ + _Card( + child: DmRouteRail( + pickup: booking.pickup.title, + pickupNote: booking.pickup.sub, + drop: booking.isMultiDestination + ? '${booking.destinations.length} destinations' + : booking.destination.label, + dropNote: + booking.details.exactAddress ?? + (recipient == null ? null : 'Recipient · $recipient'), + ), + ), + const SizedBox(height: 10), + + // ---- what the Miler actually collected ---------------------------- + if (booking.destinations.any((d) => d.verification != null)) ...[ + _VerificationCard(booking: booking), + const SizedBox(height: 10), + ], + + // ---- each place it went to, once there was more than one ---------- + if (booking.isMultiDestination) ...[ + _Card( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 4), + child: Text( + 'PACKAGES', + style: DmText.eyebrow.copyWith(color: DmColors.brand), + ), + ), + for (final group in booking.destinations) + _Line( + label: group.destination.label, + value: + '${group.packageCount} ' + '${group.packageCount == 1 ? 'package' : 'packages'}', + ), + const SizedBox(height: 6), + ], + ), + ), + const SizedBox(height: 10), + ], + + // ---- what it cost ------------------------------------------------- + if (!cancelled) ...[ + _Card( + padding: EdgeInsets.zero, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 12), + child: Row( + children: [ + Expanded( + child: Text( + 'PAYMENT', + style: DmText.eyebrow.copyWith(color: DmColors.brand), + ), + ), + Text( + '₹${booking.amountPaid ?? fare?.max ?? 0}', + style: DmText.monoLg.copyWith(fontSize: 22), + ), + ], + ), + ), + const Divider( + height: 1, + thickness: 1, + color: DmColors.hairline, + ), + if (fare?.parcel != null) + _Line(label: 'Parcel', value: fare!.parcel!), + _Line( + label: 'Base fare', + value: fare == null ? '—' : '₹${fare.min}', + ), + if (booking.routeKm != null) + _Line(label: 'Distance', value: '${booking.routeKm} km'), + _Line( + label: 'Weight adjustment', + value: fare == null || booking.amountPaid == null + ? '—' + : '₹${booking.amountPaid! - fare.min}', + ), + if (booking.totalWeightLabel != null) + _Line( + label: 'Verified weight', + value: booking.totalWeightLabel!, + ), + const Divider( + height: 1, + thickness: 1, + color: DmColors.hairline, + ), + _Line( + label: 'Total paid', + value: '₹${booking.amountPaid ?? fare?.max ?? 0}', + strong: true, + ), + if (fare?.paymentMethod != null) + _Line(label: 'Method', value: fare!.paymentMethod!), + const SizedBox(height: 6), + ], + ), + ), + const SizedBox(height: 10), + ], + + // ---- the facts ---------------------------------------------------- + DmSummaryCard( + children: [ + DmSummaryRow( + label: 'Booking reference', + value: booking.reference, + mono: true, + ), + for (final group in booking.destinations) + if (group.trackingId != null) + DmSummaryRow( + label: booking.isMultiDestination + ? 'Tracking · ${group.destination.districtName}' + : 'Tracking number', + value: group.trackingId!, + mono: true, + ), + DmSummaryRow(label: 'Pickup window', value: slot?.label ?? '—'), + if (recipient != null) + DmSummaryRow(label: 'Recipient', value: recipient), + if (booking.miler != null) + DmSummaryRow( + label: 'Collected by', + value: booking.miler!.name, + secondary: booking.miler!.vehicle, + ), + if (cancelled && booking.cancelReason != null) + DmSummaryRow( + label: 'Cancellation reason', + value: booking.cancelReason!, + ), + ], + ), + + // ---- what happened, when ----------------------------------------- + if (!cancelled) ...[ + const SizedBox(height: 10), + DmMilestones(booking: booking, title: 'Journey'), + ], + ], + ), + bottomNavigationBar: DmFooter( + edge: true, + children: [ + DmButton( + label: 'Need help with this order?', + kind: DmButtonKind.outline, + onPressed: () => DmToast.show( + context, + 'Support chat is not available yet', + ), + ), + ], + ), + ); + } +} + +/// Weight and photographs taken by the Miler at the door. +/// +/// This is the customer's record of what was handed over, and the weight the +/// final price was calculated from. +class _VerificationCard extends StatelessWidget { + const _VerificationCard({required this.booking}); + + final Booking booking; + + @override + Widget build(BuildContext context) { + final groups = booking.destinations + .where((g) => g.verification != null) + .toList(); + final first = groups.first.verification!; + + return _Card( + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 12), + child: Row( + children: [ + Expanded( + child: Text( + 'COLLECTED AT YOUR DOOR', + style: DmText.eyebrow.copyWith(color: DmColors.brand), + ), + ), + const SizedBox(width: 10), + const Flexible( + child: DmTag( + 'Miler verified', + tone: DmTagTone.ok, + icon: LucideIcons.badgeCheck, + ), + ), + ], + ), + ), + for (var i = 0; i < groups.length; i++) ...[ + if (i > 0) + const Padding( + padding: EdgeInsets.only(left: 18), + child: Divider( + height: 1, + thickness: 1, + color: DmColors.hairline, + ), + ), + _VerifiedGroup( + group: groups[i], + showDestination: booking.isMultiDestination, + ), + ], + Padding( + padding: const EdgeInsets.fromLTRB(18, 4, 18, 16), + child: Text( + 'Weighed and photographed by ' + '${first.capturedBy ?? 'your Miler'} on ' + '${dateLabel(first.capturedAt)}.', + style: DmText.small.copyWith(fontSize: 12.5), + ), + ), + ], + ), + ); + } +} + +class _VerifiedGroup extends StatelessWidget { + const _VerifiedGroup({required this.group, required this.showDestination}); + + final DestinationGroup group; + final bool showDestination; + + @override + Widget build(BuildContext context) { + final verification = group.verification!; + + return Padding( + padding: const EdgeInsets.fromLTRB(18, 12, 18, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + showDestination + ? group.destination.label + : '${group.packageCount} ' + '${group.packageCount == 1 ? 'package' : 'packages'}', + style: DmText.bodyStrong.copyWith(fontSize: 15), + ), + ), + const SizedBox(width: 10), + Text( + verification.weightLabel, + style: DmText.monoLg.copyWith(fontSize: 17), + ), + ], + ), + if (showDestination) ...[ + const SizedBox(height: 2), + Text( + '${group.packageCount} ' + '${group.packageCount == 1 ? 'package' : 'packages'}', + style: DmText.small, + ), + ], + const SizedBox(height: 12), + SizedBox( + height: 76, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.zero, + itemCount: verification.photos.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (context, i) => + _ParcelPhoto( + source: verification.photos[i], + index: i + 1, + total: verification.photos.length), + ), + ), + ], + ), + ); + } +} + +/// A photograph the Miler took of this parcel at pickup. +/// +/// [source] is whatever `ParcelVerification.photos` carried. It is a fetchable +/// URL once the images are stored remotely, and until then it is an opaque +/// reference — so the frame is drawn either way and the image only when there +/// is genuinely one to load. A broken URL falls back to the same frame rather +/// than a broken-image glyph. +class _ParcelPhoto extends StatelessWidget { + const _ParcelPhoto({ + required this.source, + required this.index, + required this.total, + }); + + final String source; + final int index; + final int total; + + bool get _isFetchable => + source.startsWith('http://') || source.startsWith('https://'); + + @override + Widget build(BuildContext context) { + return Semantics( + label: 'Pickup photo $index of $total', + image: true, + child: Container( + width: 76, + height: 76, + decoration: BoxDecoration( + color: DmColors.surfaceAlt, + border: Border.all(color: DmColors.border), + borderRadius: DmRadius.all(DmRadius.xs), + ), + clipBehavior: Clip.antiAlias, + child: Stack( + fit: StackFit.expand, + children: [ + const Center( + child: Icon( + LucideIcons.package, + size: 26, + color: DmColors.ink4, + ), + ), + if (_isFetchable) + Image.network( + source, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + Positioned( + left: 6, + bottom: 5, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: DmColors.surface, + borderRadius: DmRadius.all(DmRadius.pill), + ), + child: Text( + '$index of $total', + style: DmText.monoSm.copyWith(fontSize: 10.5, color: DmColors.ink), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _Card extends StatelessWidget { + const _Card({required this.child, this.padding = const EdgeInsets.all(16)}); + + final Widget child; + final EdgeInsets padding; + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + decoration: BoxDecoration( + color: DmColors.surface, + border: Border.all(color: DmColors.border), + borderRadius: DmRadius.all(DmRadius.md), + ), + child: child, + ); + } +} + +class _Line extends StatelessWidget { + const _Line({required this.label, required this.value, this.strong = false}); + + final String label; + final String value; + final bool strong; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text( + label, + style: strong ? DmText.cardTitle : DmText.small, + ), + ), + const SizedBox(width: 12), + // Figures are mono so a column of them lines up and can be compared + // without being read. + // + // Sized to its own content rather than made a second flex child: two + // flex children split the row between them and left the figure + // floating in the middle instead of on the card's right edge. The + // cap is what keeps that honest at large text — a long value ("Cash + // at doorstep" at 1.8x) wraps inside it instead of pushing the row + // off the card. + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 170), + child: Text( + value, + textAlign: TextAlign.right, + style: DmText.mono.copyWith( + fontSize: strong ? 15 : 13.5, + color: strong ? DmColors.brand : DmColors.ink, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/screens/order_row.dart b/lib/ui/screens/order_row.dart new file mode 100644 index 0000000..0bf4df3 --- /dev/null +++ b/lib/ui/screens/order_row.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; + +import '../../data/models.dart'; +import '../../state/app_scope.dart'; +import '../format.dart'; +import '../tokens.dart'; +import '../widgets/cards.dart'; +import '../widgets/misc.dart'; +import '../widgets/route_rail.dart'; +import 'booking/booking_routes.dart'; +import 'order_details_screen.dart'; +import 'tracking_screen.dart'; + +/// One row in Orders, as a pickup → drop card. +/// +/// Before collection the card is the pickup. Afterwards each destination has +/// its own tracking number and journey, so it gets its own card with a quiet +/// line back to the visit they were collected in. +/// +/// ── Status first, reference beside it ── +/// +/// The state and the identifier sit together in a washed strip across the top, +/// and the route sits underneath. A customer scanning this list is asking one +/// question — what is happening to my parcel — and the answer is now the first +/// thing on every card, at the same height on each, instead of being a pill +/// tucked under a route of variable length. +class OrderCard extends StatelessWidget { + const OrderCard({super.key, required this.entry}); + + final OrderEntry entry; + + @override + Widget build(BuildContext context) { + final app = AppScope.read(context); + final booking = entry.booking; + final group = entry.group; + final live = booking.status == BookingStatus.active; + + final stage = group?.stage ?? booking.stage; + final (label, tone) = switch (booking.status) { + BookingStatus.cancelled => ('Cancelled', DmPillTone.off), + BookingStatus.completed => ('Delivered', DmPillTone.done), + BookingStatus.active => ( + stage.milestoneLabel, + // Crimson-filled only while something is actually moving towards the + // customer. Everything else in progress holds the outline, so a list + // of six active orders has at most a couple of filled pills on it. + _isMoving(stage) ? DmPillTone.live : DmPillTone.neutral, + ), + }; + + final drop = entry.isPickup + ? (booking.isMultiDestination + ? '${booking.destinations.length} destinations' + : booking.destination.label) + : group!.destination.label; + + final reference = entry.isPickup + ? booking.reference + : (group!.trackingId ?? booking.reference); + + final packages = entry.isPickup ? booking.totalPackages : group!.packageCount; + final meta = [ + '$packages ${packages == 1 ? 'package' : 'packages'}', + if (entry.isPickup && booking.isMultiDestination) + '${booking.destinations.length} destinations', + if (booking.status == BookingStatus.completed && booking.amountPaid != null) + '₹${booking.amountPaid} paid' + else + dateLabel(booking.createdAt), + ].join(' · '); + + return DmCard( + // The spine marks the one card the customer has to do something about. + rail: live && stage == JourneyStage.arrived, + onTap: () { + app.openTracking(booking.reference); + Navigator.of(context).push( + live + ? bookingRoute( + BookingRoutes.tracking, + (_) => TrackingScreen(focusedDestination: entry.destinationIndex), + ) + : MaterialPageRoute( + builder: (_) => OrderDetailsScreen(reference: booking.reference), + ), + ); + }, + children: [ + DmCardCell( + background: DmColors.brandSoft, + padding: const EdgeInsets.fromLTRB(14, 11, 14, 11), + child: Row( + children: [ + Flexible(child: DmPill(label, tone: tone)), + const SizedBox(width: 10), + Expanded( + child: Text( + reference, + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.mono, + ), + ), + ], + ), + ), + DmCardCell( + padding: const EdgeInsets.fromLTRB(16, 15, 16, 15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DmRouteRail( + compact: true, + pickup: booking.pickup.title, + drop: drop, + ), + const SizedBox(height: 11), + Text(meta, style: DmText.tiny), + if (entry.collectedTogether) ...[ + const SizedBox(height: 4), + Text.rich( + TextSpan( + style: DmText.tiny.copyWith(color: DmColors.ink4), + children: [ + const TextSpan(text: 'Collected on '), + TextSpan( + text: booking.reference, + style: DmText.monoSm.copyWith(color: DmColors.ink4), + ), + ], + ), + ), + ], + ], + ), + ), + ], + ); + } + + /// True while the parcel — or the Miler coming for it — is physically on the + /// move, which is the only time the loud pill is earned. + static bool _isMoving(JourneyStage stage) => switch (stage) { + JourneyStage.booked || + JourneyStage.onTheWay || + JourneyStage.arrived || + JourneyStage.outForDelivery => + true, + _ => false, + }; +} diff --git a/lib/ui/screens/orders_screen.dart b/lib/ui/screens/orders_screen.dart new file mode 100644 index 0000000..cf7e9ed --- /dev/null +++ b/lib/ui/screens/orders_screen.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../data/models.dart'; +import '../../state/app_scope.dart'; +import '../format.dart'; +import '../tokens.dart'; +import '../widgets/buttons.dart'; +import '../widgets/chrome.dart'; +import '../widgets/states.dart'; +import 'order_row.dart'; + +/// Orders — current bookings and past ones, as route cards. +/// +/// The month, the title and the filter all live inside one crimson field at +/// the top. That is deliberate: the filter is this screen's only control, and +/// recessing it into the header means the list below can be pure content with +/// no chrome of its own — no title bar competing with the first card, no +/// separate count row, because each tab carries its own figure. +class OrdersScreen extends StatelessWidget { + const OrdersScreen({super.key}); + + static const _tabs = BookingStatus.values; + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + final rows = app.entriesOf(app.ordersTab); + final index = _tabs.indexOf(app.ordersTab); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DmBrandHeader( + eyebrow: monthLabel(DateTime.now()), + title: 'Orders', + action: DmIconButton( + icon: LucideIcons.refreshCw, + tooltip: 'Refresh', + background: DmColors.onBrandRaise, + foreground: Colors.white, + border: Colors.transparent, + radius: DmRadius.lg, + size: 42, + onPressed: app.refreshOrders, + ), + child: DmSegmented( + labels: const ['Active', 'Completed', 'Cancelled'], + counts: [for (final tab in _tabs) app.entriesOf(tab).length], + index: index < 0 ? 0 : index, + onChanged: (i) => app.setOrdersTab(_tabs[i]), + ), + ), + Expanded( + child: RefreshIndicator( + color: DmColors.brand, + backgroundColor: DmColors.surface, + onRefresh: app.refreshOrders, + child: ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 16, DmSpace.pad, 28), + itemCount: rows.isEmpty ? 1 : rows.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (context, i) => + rows.isEmpty ? _empty(app.ordersTab) : OrderCard(entry: rows[i]), + ), + ), + ), + ], + ); + } + + Widget _empty(BookingStatus tab) { + final (title, message) = switch (tab) { + BookingStatus.active => ( + 'No active pickups', + 'Book a pickup and it will show up here.', + ), + BookingStatus.completed => ( + 'Nothing completed yet', + 'Delivered orders will appear here.', + ), + BookingStatus.cancelled => ( + 'No cancelled bookings', + 'Anything you cancel will appear here.', + ), + }; + return DmEmptyState( + icon: LucideIcons.package, + title: title, + message: message, + ); + } +} diff --git a/lib/ui/screens/sheets/cancel_sheet.dart b/lib/ui/screens/sheets/cancel_sheet.dart new file mode 100644 index 0000000..3e4a7ca --- /dev/null +++ b/lib/ui/screens/sheets/cancel_sheet.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../data/models.dart'; +import '../../../state/app_scope.dart'; +import '../../tokens.dart'; +import '../../widgets/buttons.dart'; +import '../../widgets/feedback.dart'; + +/// 17. Cancel booking confirmation. +/// +/// Only reachable while the backend still allows cancellation — see +/// `DoormileApi.isCancellable`. Returns true when the booking was cancelled. +Future showCancelSheet(BuildContext context, Booking booking) { + return showDmSheet( + context: context, + builder: (context) => _CancelBody(booking: booking), + ); +} + +class _CancelBody extends StatefulWidget { + const _CancelBody({required this.booking}); + final Booking booking; + + @override + State<_CancelBody> createState() => _CancelBodyState(); +} + +class _CancelBodyState extends State<_CancelBody> { + static const _reasons = [ + 'Booked by mistake', + 'Package not ready', + 'Sending it another day', + 'Changed the destination', + 'Other', + ]; + + String? _reason; + bool _busy = false; + + Future _cancel() async { + setState(() => _busy = true); + final app = AppScope.read(context); + final navigator = Navigator.of(context); + try { + await app.cancelBooking(widget.booking, _reason); + unawaited(HapticFeedback.mediumImpact()); + navigator.pop(true); + } catch (_) { + if (!mounted) return; + setState(() => _busy = false); + DmToast.show(context, "Couldn't cancel. Try again."); + } + } + + @override + Widget build(BuildContext context) { + final app = AppScope.read(context); + final slot = app.slotById(widget.booking.slotId); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DmSheetHeader( + title: 'Cancel this pickup?', + subtitle: + '${widget.booking.reference} · ${slot?.label ?? 'Pickup scheduled'}. ' + 'You can cancel free of charge until the Miler collects your package.', + ), + Text('REASON (OPTIONAL)', style: DmText.eyebrow.copyWith(color: DmColors.brand)), + const SizedBox(height: 10), + for (final reason in _reasons) ...[ + _ReasonTile( + label: reason, + selected: _reason == reason, + onTap: () => setState(() => _reason = reason), + ), + const SizedBox(height: 8), + ], + const SizedBox(height: 12), + DmButton( + label: 'Keep my pickup', + onPressed: _busy ? null : () => Navigator.of(context).pop(false), + ), + const SizedBox(height: 4), + DmButton( + label: 'Yes, cancel pickup', + kind: DmButtonKind.ghost, + danger: true, + busy: _busy, + busyLabel: 'Cancelling…', + onPressed: _cancel, + ), + ], + ); + } +} + +class _ReasonTile extends StatelessWidget { + const _ReasonTile({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: const Duration(milliseconds: 140), + constraints: const BoxConstraints(minHeight: 46), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: selected ? DmColors.brand : DmColors.surface, + borderRadius: DmRadius.all(DmRadius.sm), + border: Border.all( + color: selected ? DmColors.brand : DmColors.border, + ), + boxShadow: selected ? DmShadow.liftSm : null, + ), + child: Text( + label, + style: DmText.label.copyWith( + fontSize: 13, + color: selected ? Colors.white : DmColors.ink2, + ), + ), + ), + ); + } +} diff --git a/lib/ui/screens/sheets/place_search_sheet.dart b/lib/ui/screens/sheets/place_search_sheet.dart new file mode 100644 index 0000000..68b1ebd --- /dev/null +++ b/lib/ui/screens/sheets/place_search_sheet.dart @@ -0,0 +1,177 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../../data/models.dart'; +import '../../../state/app_scope.dart'; +import '../../../state/app_state.dart'; +import '../../tokens.dart'; +import '../../widgets/cards.dart'; +import '../../widgets/feedback.dart'; +import '../../widgets/states.dart'; + +/// Alias used from Review, where the sheet edits the pickup point in place. +Future showPlaceSearchSheetForReview(BuildContext context) => + showPlaceSearchSheet(context); + +/// Pickup-location picker. Returns the chosen [Place], or null if dismissed. +Future showPlaceSearchSheet(BuildContext context) { + return showDmSheet( + context: context, + builder: (context) => const _PlaceSearchBody(), + ); +} + +class _PlaceSearchBody extends StatefulWidget { + const _PlaceSearchBody(); + + @override + State<_PlaceSearchBody> createState() => _PlaceSearchBodyState(); +} + +class _PlaceSearchBodyState extends State<_PlaceSearchBody> { + final _query = TextEditingController(); + Timer? _debounce; + late final AppState _app = AppScope.read(context); + late Future> _results = _app.searchPlaces(''); + + @override + void dispose() { + _debounce?.cancel(); + _query.dispose(); + super.dispose(); + } + + void _onChanged(String value) { + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 220), () { + setState(() => _results = _app.searchPlaces(value)); + }); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const DmSheetHeader( + title: 'Pickup location', + subtitle: 'Search or pick a saved place.', + ), + TextField( + controller: _query, + onChanged: _onChanged, + autofocus: false, + cursorColor: DmColors.brand, + style: DmText.field, + decoration: InputDecoration( + hintText: 'Search street, area or landmark', + hintStyle: DmText.field.copyWith( + color: DmColors.ink3, + fontWeight: FontWeight.w500, + ), + prefixIcon: const Icon( + LucideIcons.search, + size: 20, + color: DmColors.ink3, + ), + filled: true, + fillColor: DmColors.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + border: _border(DmColors.lineStrong, 1), + enabledBorder: _border(DmColors.lineStrong, 1), + focusedBorder: _border(DmColors.brand, 2), + ), + ), + const SizedBox(height: 8), + Flexible( + child: SingleChildScrollView( + child: FutureBuilder>( + future: _results, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.only(top: 12), + child: DmSkeleton(rows: 3), + ); + } + final places = snap.data ?? const []; + if (places.isEmpty) { + return const DmEmptyState( + icon: LucideIcons.searchX, + title: 'No places found', + message: 'Try a different street or landmark.', + ); + } + return Column( + children: [ + for (final place in places) + _PlaceRow( + place: place, + onTap: () => Navigator.of(context).pop(place), + ), + ], + ); + }, + ), + ), + ), + ], + ); + } + + OutlineInputBorder _border(Color color, double width) => OutlineInputBorder( + borderRadius: DmRadius.all(DmRadius.sm), + borderSide: BorderSide(color: color, width: width), + ); +} + +class _PlaceRow extends StatelessWidget { + const _PlaceRow({required this.place, required this.onTap}); + + final Place place; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: 60), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 4), + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: DmColors.line)), + ), + child: Row( + children: [ + const DmTile(icon: LucideIcons.mapPin), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + place.title, + style: DmText.cardTitle, + ), + Text( + place.sub, + style: DmText.small, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/screens/shell_screen.dart b/lib/ui/screens/shell_screen.dart new file mode 100644 index 0000000..a6d7b20 --- /dev/null +++ b/lib/ui/screens/shell_screen.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../tokens.dart'; +import 'account_screen.dart'; +import 'home_screen.dart'; +import 'orders_screen.dart'; + +/// Bottom-navigation shell: Home · Orders · Account. +/// +/// Booking and tracking screens are pushed on top, so the tab bar only ever +/// shows on the three destinations that need it. +class ShellScreen extends StatefulWidget { + const ShellScreen({super.key, this.initialTab = 0}); + + final int initialTab; + + static ShellScreenState? of(BuildContext context) => + context.findAncestorStateOfType(); + + @override + State createState() => ShellScreenState(); +} + +class ShellScreenState extends State { + late int _index = widget.initialTab; + + void goToTab(int index) { + if (index == _index) return; + HapticFeedback.selectionClick(); + setState(() => _index = index); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: DmColors.canvas, + body: IndexedStack( + index: _index, + children: const [HomeScreen(), OrdersScreen(), AccountScreen()], + ), + bottomNavigationBar: _TabBar(index: _index, onChanged: goToTab), + ); + } +} + +/// The tab bar, fixed to the bottom edge. +/// +/// ── Why it is no longer a floating pill-shaped bar ── +/// +/// It used to hover 10pt above the bottom with a 28pt radius and a shadow +/// under it, which put a second rounded surface between the content and the +/// gesture bar and made every screen end in two stacked edges. It now sits on +/// the edge with a single hairline above it, like the footers in the rest of +/// the app. +/// +/// The indicator does the work instead: a crimson pill that travels to the tab +/// you pick, with the glyph turning white as it arrives. Selection reads as one +/// object moving rather than three tabs changing colour in place — and the +/// label under it stays crimson, so the tab is still identifiable in the +/// half-second the pill is in flight. +class _TabBar extends StatelessWidget { + const _TabBar({required this.index, required this.onChanged}); + + final int index; + final ValueChanged onChanged; + + /// Lucide, not Material. One 1.5px stroke family at one weight, so the three + /// glyphs read as a set instead of three different drawing styles — and the + /// same set the pushed screens use. + static const _items = [ + (label: 'Home', icon: LucideIcons.house), + (label: 'Orders', icon: LucideIcons.package), + (label: 'Account', icon: LucideIcons.userRound), + ]; + + /// The travelling pill's size. Narrower than its slot so it reads as an + /// object sitting in the row rather than as a selected cell. + static const double _pillWidth = 62; + static const double _pillHeight = 34; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + color: DmColors.surface, + border: Border(top: BorderSide(color: DmColors.border)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(6, 9, 6, 7), + child: SizedBox( + // The row is the pill plus the label, and the label is type: at + // 1.8x accessibility text a fixed 58 clipped it by two points. The + // bar gets taller instead, which is what a larger-text user asked + // for. + height: _pillHeight + + 5 + + MediaQuery.textScalerOf(context).scale(11.5) * 1.3, + child: LayoutBuilder( + builder: (context, constraints) { + final slot = constraints.maxWidth / _items.length; + return Stack( + children: [ + AnimatedPositioned( + duration: DmMotion.slow, + curve: DmMotion.glide, + left: slot * index + (slot - _pillWidth) / 2, + width: _pillWidth, + top: 0, + height: _pillHeight, + child: DecoratedBox( + decoration: BoxDecoration( + color: DmColors.brand, + borderRadius: DmRadius.all(DmRadius.pill), + boxShadow: DmShadow.liftSm, + ), + ), + ), + Row( + children: [ + for (var i = 0; i < _items.length; i++) + Expanded( + child: _TabButton( + label: _items[i].label, + icon: _items[i].icon, + selected: index == i, + onTap: () => onChanged(i), + ), + ), + ], + ), + ], + ); + }, + ), + ), + ), + ), + ); + } +} + +class _TabButton extends StatelessWidget { + const _TabButton({ + required this.label, + required this.icon, + required this.selected, + required this.onTap, + }); + + final String label; + final IconData icon; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + selected: selected, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + SizedBox( + height: _TabBar._pillHeight, + child: Center( + child: AnimatedSwitcher( + duration: DmMotion.base, + child: Icon( + icon, + key: ValueKey(selected), + size: 22, + color: selected ? Colors.white : DmColors.ink3, + ), + ), + ), + ), + const SizedBox(height: 5), + AnimatedDefaultTextStyle( + duration: DmMotion.base, + curve: DmMotion.ease, + style: DmText.label.copyWith( + fontSize: 11.5, + height: 1.0, + color: selected ? DmColors.brand : DmColors.ink3, + fontWeight: selected ? FontWeight.w700 : FontWeight.w600, + ), + child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis), + ), + ], + ), + ), + ); + } +} diff --git a/lib/ui/screens/tracking_screen.dart b/lib/ui/screens/tracking_screen.dart new file mode 100644 index 0000000..b234058 --- /dev/null +++ b/lib/ui/screens/tracking_screen.dart @@ -0,0 +1,878 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../data/models.dart'; +import '../../data/app_config.dart'; +import '../../state/app_scope.dart'; +import '../../state/app_state.dart'; +import '../tokens.dart'; +import '../widgets/buttons.dart'; +import '../widgets/cards.dart'; +import '../widgets/chrome.dart'; +import '../widgets/feedback.dart'; +import '../widgets/milestones.dart'; +import '../widgets/misc.dart'; +import '../widgets/pieces.dart'; +import '../widgets/route_map.dart'; +import '../widgets/route_rail.dart'; +import '../widgets/states.dart'; +import '../widgets/summary.dart'; +import 'booking/booking_routes.dart'; +import 'booking/pickup_location_screen.dart'; +import 'sheets/cancel_sheet.dart'; + +/// Live tracking. +/// +/// One screen for the whole journey. While a Miler is being matched it shows a +/// radar; once one is assigned it shows their live position, who they are, and +/// how to reach them; after pickup it follows the order to the door. +/// Cancellation is offered only while the backend still allows it. +class TrackingScreen extends StatelessWidget { + const TrackingScreen({super.key, this.focusedDestination}); + + /// When set, this screen follows one destination's own order rather than the + /// pickup as a whole. Only meaningful once the orders exist. + final int? focusedDestination; + + @override + Widget build(BuildContext context) { + final app = AppScope.of(context); + final booking = app.trackedBooking; + + if (booking == null) { + return Scaffold( + backgroundColor: DmColors.canvas, + appBar: const DmTopBar(title: 'Live tracking'), + body: const DmEmptyState( + icon: LucideIcons.package, + title: 'Nothing to track', + message: 'Book a pickup to get started.', + ), + ); + } + + final cancelled = booking.isCancelled; + final slot = app.slotById(booking.slotId); + + // A focused destination follows its own journey; the pickup follows its own. + final focused = + (focusedDestination != null && + focusedDestination! < booking.destinations.length && + booking.destinations[focusedDestination!].trackingId != null) + ? booking.destinations[focusedDestination!] + : null; + final stage = focused?.stage ?? booking.stage; + + final milerEnRoute = + !cancelled && + booking.miler != null && + stage.index >= JourneyStage.assigned.index && + stage.index <= JourneyStage.pickedUp.index; + final assigning = !cancelled && stage == JourneyStage.booked; + + return Scaffold( + backgroundColor: DmColors.canvas, + appBar: DmTopBar( + title: cancelled ? 'Booking' : 'Live tracking', + // The reference belongs in the bar, not in a card three scrolls down: + // it is what a customer reads out when they call about this parcel. + trailing: Text( + focused?.trackingId ?? booking.displayId, + style: DmText.mono, + ), + ), + body: ListView( + padding: const EdgeInsets.only(bottom: 24), + children: [ + _StatusHeader(booking: booking, slot: slot, focused: focused), + + if (assigning) + const _AssigningHero() + else if (!cancelled) + Padding( + padding: const EdgeInsets.fromLTRB( + DmSpace.pad, + 0, + DmSpace.pad, + 10, + ), + // The map is a cell of a card, not a floating rounded rectangle + // of its own: it gets the card's edge, and the line under it + // carries the fact the picture cannot state precisely. + child: DmCard( + children: [ + DmRouteMap( + height: 178, + radius: 0, + pickup: booking.pickup, + destination: (focused ?? booking.destinations.first).hub, + showMiler: milerEnRoute, + progress: _routeProgress(stage), + pickupLabel: booking.pickup.title, + destinationLabel: booking.destination.districtName, + badge: milerEnRoute && booking.milerDistanceKm != null + ? _Badge( + icon: LucideIcons.bike, + label: booking.milerDistanceKm == 0 + ? 'At your door' + : '${booking.miler?.name ?? 'Your Miler'}, ' + '${booking.milerDistanceKm} km away', + ) + : null, + ), + DmCardCell( + padding: const EdgeInsets.fromLTRB(16, 12, 10, 12), + child: Row( + children: [ + const Icon( + LucideIcons.clock, + size: 15, + color: DmColors.ink3, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + // Short enough to sit beside Refresh without + // ellipsizing, which is the whole job of this line. + milerEnRoute && booking.milerEtaMinutes != null + ? 'Pickup in about ${booking.milerEtaMinutes} min' + : 'Tracking your parcel live', + style: DmText.small, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + DmTextAction( + label: 'Refresh', + onPressed: app.refreshOrders, + ), + ], + ), + ), + ], + ), + ), + + if (assigning && booking.milersInZone > 0) + Padding( + padding: const EdgeInsets.fromLTRB( + DmSpace.pad, + 0, + DmSpace.pad, + 10, + ), + child: DmInfoBanner( + icon: LucideIcons.users, + title: + '${booking.milersInZone} verified Milers active in your zone', + message: 'Most pickups are accepted within a few minutes.', + ), + ), + + if (milerEnRoute) + _MilerCard(person: booking.miler!, role: 'Your Miler'), + if (!cancelled && + stage == JourneyStage.outForDelivery && + booking.deliveryAgent != null) + _MilerCard(person: booking.deliveryAgent!, role: 'Delivery Miler'), + + if (!cancelled) + Padding( + padding: const EdgeInsets.fromLTRB( + DmSpace.pad, + 0, + DmSpace.pad, + 10, + ), + child: _Journey(booking: booking), + ), + + if (!cancelled && booking.hasOrders && booking.isMultiDestination) + Padding( + padding: const EdgeInsets.fromLTRB( + DmSpace.pad, + 0, + DmSpace.pad, + 10, + ), + child: _YourPackages(booking: booking, focused: focused), + ), + + Padding( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10), + child: _RouteOutline( + booking: booking, + slot: slot, + focused: focused, + ), + ), + + // Stands in for backend push updates. Real stage changes arrive + // from `GET /customer/bookings/{reference}`, so this exists only + // where there is no server to send them. + if (AppConfig.showStageStepper) _StageStepper(booking: booking), + ], + ), + bottomNavigationBar: DmFooter( + edge: true, + children: _footerActions(context, app, booking), + ), + ); + } + + /// How far along the route the Miler is drawn, by stage. + static double _routeProgress(JourneyStage stage) => switch (stage) { + JourneyStage.assigned => 0.08, + JourneyStage.onTheWay => 0.42, + JourneyStage.arrived || JourneyStage.pickedUp => 0.92, + _ => 0.5, + }; + + /// The footer is a hierarchy, not a single button: the helpful action is the + /// filled one and cancelling is a text button under it. Cancel used to be a + /// full-width outlined button in the danger colour — the most prominent + /// control on a screen whose job is to reassure someone that their parcel is + /// fine. + List _footerActions(BuildContext context, AppState app, Booking booking) { + if (booking.isCancelled || booking.stage == JourneyStage.delivered) { + return [ + DmButton( + label: 'Book another pickup', + icon: LucideIcons.arrowRight, + onPressed: () { + app.startBooking(); + Navigator.of(context).pushAndRemoveUntil( + bookingRoute( + BookingRoutes.pickup, + (_) => const PickupLocationScreen(), + ), + (route) => route.isFirst, + ); + }, + ), + ]; + } + + return [ + DmButton( + label: 'Share live tracking', + icon: LucideIcons.share2, + kind: DmButtonKind.outline, + onPressed: () => DmToast.show(context, 'Sharing is coming soon'), + ), + // Backend policy decides whether cancelling is still possible. + if (app.isCancellable(booking.stage)) + DmButton( + label: 'Cancel this pickup', + kind: DmButtonKind.ghost, + danger: true, + onPressed: () async { + final cancelled = await showCancelSheet(context, booking); + if (cancelled == true && context.mounted) { + DmToast.show(context, 'Pickup cancelled'); + } + }, + ) + else + DmButton( + label: 'Need help with this order?', + kind: DmButtonKind.ghost, + onPressed: () => + DmToast.show(context, 'Support chat is not available yet'), + ), + ]; + } +} + +/// The screen's one loud surface: what is happening, right now. +/// +/// ── Why this is a filled card and not a heading ── +/// +/// The status used to be an eyebrow, a 32pt headline and a row of chips laid +/// straight onto the canvas. It said the right words, but it looked like every +/// other heading in the app, so the screen opened with no focal point and the +/// map below it — a decoration — became the loudest thing on the page. +/// +/// It is now the one crimson card the design allows per screen. It carries the +/// live pulse, the headline, the journey's own progress and the window, so the +/// whole answer is in one object above the fold. A cancelled booking drops to a +/// plain card: nothing is live, and a crimson field would keep insisting it is. +class _StatusHeader extends StatelessWidget { + const _StatusHeader({ + required this.booking, + required this.slot, + this.focused, + }); + + final Booking booking; + final PickupSlot? slot; + + /// Set when following one destination's own order. + final DestinationGroup? focused; + + @override + Widget build(BuildContext context) { + final cancelled = booking.isCancelled; + final stage = focused?.stage ?? booking.stage; + + final headline = switch (true) { + _ when cancelled => 'Booking cancelled', + _ when stage == JourneyStage.booked => 'Finding your Miler', + _ when stage == JourneyStage.assigned && booking.miler != null => + '${booking.miler!.name} is assigned', + _ when stage == JourneyStage.onTheWay && booking.miler != null => + booking.milerEtaMinutes == null + ? '${booking.miler!.name} is on the way' + : '${booking.miler!.name} is ${booking.milerEtaMinutes} minutes away', + _ when stage == JourneyStage.arrived && booking.miler != null => + '${booking.miler!.name} is at your door', + _ => stage.headline, + }; + + final sub = cancelled + ? 'This pickup was cancelled. You were not charged.' + : stage.sub; + + final total = CustomerMilestone.values.length; + final step = CustomerMilestone.of(stage).index + 1; + + if (cancelled) { + return Padding( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10), + child: DmCard( + children: [ + DmCardCell( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const DmPill('Cancelled', tone: DmPillTone.off), + const SizedBox(height: 12), + Text(headline, style: DmText.display.copyWith(fontSize: 25)), + const SizedBox(height: 6), + Text(sub, style: DmText.small), + ], + ), + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10), + child: DmCard( + tone: DmCardTone.fill, + children: [ + DmCardCell( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const DmLiveDot(size: 9, color: Colors.white), + const SizedBox(width: 4), + Text( + 'LIVE', + style: DmText.eyebrow.copyWith( + fontSize: 11, + color: Colors.white, + ), + ), + const Spacer(), + if (focused != null) + Flexible( + child: Text( + 'To ${focused!.destination.label}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: DmText.small.copyWith(color: DmColors.onBrand3), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + headline, + style: DmText.display.copyWith(fontSize: 27, color: Colors.white), + ), + const SizedBox(height: 6), + Text(sub, style: DmText.small.copyWith(color: DmColors.onBrand2)), + const SizedBox(height: 16), + DmStepBar( + progress: step / total, + steps: total, + current: step, + onFill: true, + // The milestone, not the window: the window is already the + // line underneath, and at this width it clipped to + // "Window 2:00 – 4:…". + label: CustomerMilestone.of(stage).label, + ), + const SizedBox(height: 10), + Text( + _footnote(stage), + style: DmText.tiny.copyWith(color: DmColors.onBrand3), + ), + ], + ), + ), + ], + ), + ); + } + + /// The one fact under the rail: whatever is most useful at this stage. + String _footnote(JourneyStage stage) { + if (booking.milerEtaMinutes != null && booking.milerEtaMinutes! > 0) { + return 'Arriving in about ${booking.milerEtaMinutes} min'; + } + if (stage == JourneyStage.arrived) return 'Waiting at your door'; + if (stage.index <= JourneyStage.assigned.index && slot != null) { + return '${slot!.day}, ${slot!.window}'; + } + if (booking.expectedDelivery != null) { + return 'Expected ${booking.expectedDelivery}'; + } + return CustomerMilestone.of(stage).label; + } +} + +/// The journey, compressed to what just happened. +class _Journey extends StatefulWidget { + const _Journey({required this.booking}); + + final Booking booking; + + @override + State<_Journey> createState() => _JourneyState(); +} + +class _JourneyState extends State<_Journey> { + bool _all = false; + + @override + Widget build(BuildContext context) { + return DmMilestones( + booking: widget.booking, + title: widget.booking.trackingId == null ? 'Recent updates' : 'Live journey', + maxRows: _all ? null : 3, + onExpand: () => setState(() => _all = true), + ); + } +} + +/// A chip floating on the map — "En route, 1.4 km away". +class _Badge extends StatelessWidget { + const _Badge({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(10, 7, 12, 7), + decoration: BoxDecoration( + color: DmColors.surface, + border: Border.all(color: DmColors.border), + borderRadius: DmRadius.all(6), + boxShadow: DmShadow.float, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: DmColors.brand), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.label.copyWith(fontSize: 12, color: DmColors.ink), + ), + ), + ], + ), + ); + } +} + +/// Radar shown while the backend matches a Miler. +class _AssigningHero extends StatelessWidget { + const _AssigningHero(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 20), + child: Container( + height: 200, + alignment: Alignment.center, + decoration: BoxDecoration( + color: DmColors.surface, + border: Border.all(color: DmColors.border), + borderRadius: DmRadius.all(DmRadius.md), + ), + child: DmRadarPulse( + size: 170, + child: Container( + width: 62, + height: 62, + decoration: const BoxDecoration( + color: DmColors.brand, + shape: BoxShape.circle, + ), + child: const Icon( + LucideIcons.bike, + size: 28, + color: Colors.white, + ), + ), + ), + ), + ); + } +} + +/// Who is collecting, how good they are, and how to reach them. +/// +/// A wash rather than a plain card: this is the one row on the screen about a +/// person rather than a parcel, and the tint is what separates "who" from the +/// facts stacked above and below it. The plate is mono — it is read off a +/// number plate at the door. +class _MilerCard extends StatelessWidget { + const _MilerCard({required this.person, required this.role}); + + final Person person; + final String role; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10), + child: DmCard( + tone: DmCardTone.wash, + children: [ + DmCardCell( + padding: const EdgeInsets.fromLTRB(14, 13, 14, 13), + child: Row( + children: [ + DmAvatar(initials: person.initials, size: 44), + const SizedBox(width: 13), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(person.name, style: DmText.cardTitle), + const SizedBox(height: 2), + // Absent unless the backend sent a rating or a pickup + // count. A default would put an invented reputation on a + // real person's card. + if (person.credentials != null) + Row( + children: [ + const Icon( + LucideIcons.star, + size: 12, + color: DmColors.brand, + ), + const SizedBox(width: 5), + Flexible( + child: Text( + person.credentials!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.tiny, + ), + ), + ], + ) + else + Text(role, style: DmText.tiny), + ], + ), + ), + const SizedBox(width: 10), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 122), + child: DmTag(person.vehicle, mono: true), + ), + ], + ), + ), + DmCardCell( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 13), + child: Row( + children: [ + DmMiniButton( + label: 'Call', + icon: LucideIcons.phone, + filled: true, + onPressed: () => DmToast.show(context, 'Calling ${person.name}…'), + ), + const SizedBox(width: 8), + DmMiniButton( + label: 'Message', + icon: LucideIcons.messageSquare, + onPressed: () => + DmToast.show(context, 'Chat is not available yet'), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// Where it is going, and the few facts worth keeping on screen. +class _RouteOutline extends StatelessWidget { + const _RouteOutline({ + required this.booking, + required this.slot, + this.focused, + }); + + final Booking booking; + final PickupSlot? slot; + final DestinationGroup? focused; + + @override + Widget build(BuildContext context) { + final group = focused; + final recipient = (group ?? booking.destinations.first).details.recipient; + + return DmCard( + children: [ + DmCardCell( + child: DmRouteRail( + pickup: booking.pickup.title, + pickupNote: booking.pickup.sub, + drop: group != null + ? group.destination.label + : booking.isMultiDestination + ? '${booking.destinations.length} destinations' + : booking.destination.label, + dropNote: group != null + ? '${group.packageCount} ' + '${group.packageCount == 1 ? 'package' : 'packages'}' + : booking.isMultiDestination + ? booking.packageSummary + : booking.details.exactAddress ?? + (recipient == null + ? 'Exact address collected at pickup' + : 'Recipient · $recipient'), + ), + ), + DmSummaryRow(label: 'Pickup window', value: slot?.label ?? '—'), + DmSummaryRow( + label: (group?.trackingId ?? booking.trackingId) == null + ? 'Booking reference' + : 'Tracking number', + value: group?.trackingId ?? booking.displayId, + mono: true, + secondary: group == null + ? null + : 'Collected together · Pickup ${booking.reference}', + ), + ], + ); + } +} + +/// The individual orders a completed pickup produced. +/// +/// Deliberately absent until the backend has actually created them: before +/// collection the customer has one scheduled visit, not several shipments. +class _YourPackages extends StatelessWidget { + const _YourPackages({required this.booking, this.focused}); + + final Booking booking; + + /// The one being followed, so the list can mark it. + final DestinationGroup? focused; + + @override + Widget build(BuildContext context) { + return DmCard( + children: [ + DmCardCell( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), + child: Row( + children: [ + Expanded( + child: Text( + 'YOUR PACKAGES', + style: DmText.eyebrow.copyWith(color: DmColors.brand), + ), + ), + Flexible( + child: Text( + focused == null + ? 'Collected in one visit' + : 'Pickup ${booking.reference}', + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.tiny, + ), + ), + ], + ), + ), + for (final group in booking.destinations) + _PackageRow( + group: group, + isFocused: identical(group, focused), + ), + ], + ); + } +} + +class _PackageRow extends StatelessWidget { + const _PackageRow({required this.group, this.isFocused = false}); + + final DestinationGroup group; + final bool isFocused; + + @override + Widget build(BuildContext context) { + final stage = group.stage; + return DmCardCell( + background: isFocused ? DmColors.brandSoft : null, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(group.destination.label, style: DmText.cardTitle), + const SizedBox(height: 3), + Text.rich( + TextSpan( + style: DmText.tiny, + children: [ + TextSpan( + text: group.trackingId ?? '—', + style: DmText.monoSm, + ), + TextSpan( + text: ' · ${group.packageCount} ' + '${group.packageCount == 1 ? 'package' : 'packages'}', + ), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 10), + if (stage != null) + Flexible( + child: DmPill( + stage.milestoneLabel, + tone: stage == JourneyStage.delivered + ? DmPillTone.done + : DmPillTone.neutral, + ), + ), + ], + ), + ); + } +} + +/// The backend's QA stage helper, as a control. +/// +/// It is not a simulator: each tap posts to +/// `POST /customer/ops/bookings/{reference}/stage` and the screen then re-reads +/// the booking, so what it shows is what the **server** recorded. Off entirely +/// unless the build asked for it with `DM_ALLOW_STAGE_OVERRIDE`, and never on +/// production or in a release. +class _StageStepper extends StatelessWidget { + const _StageStepper({required this.booking}); + + final Booking booking; + + @override + Widget build(BuildContext context) { + final app = AppScope.read(context); + final index = booking.stage.index; + final locked = booking.isCancelled; + + return Padding( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 4, DmSpace.pad, 20), + child: Container( + padding: const EdgeInsets.fromLTRB(16, 10, 10, 10), + decoration: BoxDecoration( + color: DmColors.surfaceAlt, + border: Border.all(color: DmColors.border), + borderRadius: DmRadius.all(DmRadius.md), + ), + child: Row( + children: [ + Expanded( + child: Text( + 'Move stage (QA)', + style: DmText.label.copyWith(color: DmColors.ink3), + ), + ), + _StepButton( + icon: LucideIcons.minus, + onTap: locked || index == 0 + ? null + : () => app.advanceStage(booking, JourneyStage.at(index - 1)), + ), + const SizedBox(width: 8), + _StepButton( + icon: LucideIcons.plus, + onTap: locked || index == JourneyStage.values.length - 1 + ? null + : () => app.advanceStage(booking, JourneyStage.at(index + 1)), + ), + ], + ), + ), + ); + } +} + +class _StepButton extends StatelessWidget { + const _StepButton({required this.icon, this.onTap}); + + final IconData icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return Opacity( + opacity: enabled ? 1 : 0.35, + child: Material( + color: DmColors.surface, + borderRadius: DmRadius.all(DmRadius.xs), + child: InkWell( + borderRadius: DmRadius.all(DmRadius.xs), + onTap: onTap, + child: Ink( + width: 38, + height: 38, + decoration: BoxDecoration( + borderRadius: DmRadius.all(DmRadius.xs), + border: Border.all(color: DmColors.border), + ), + child: Icon(icon, size: 18, color: DmColors.ink2), + ), + ), + ), + ); + } +} diff --git a/lib/ui/tokens.dart b/lib/ui/tokens.dart new file mode 100644 index 0000000..e5a2aa6 --- /dev/null +++ b/lib/ui/tokens.dart @@ -0,0 +1,395 @@ +import 'package:flutter/material.dart'; + +/// Design tokens. Every colour, radius, shadow and text style in the app comes +/// from here — screens never hardcode a value. +/// +/// Direction (2026-09-15 rebuild, from the 14-screen design): +/// +/// **Edges, not elevation.** Surfaces are separated by a 1px warm hairline and +/// a 10pt radius, not by a shadow. The only shadows left in the system are the +/// crimson glow under the one committing action on a screen, the float under a +/// control that genuinely hovers over a map, and the sheet lift. A screen that +/// drops shadows everywhere has no way left to say "this one matters". +/// +/// **One accent, three intensities.** Crimson fills the header and the primary +/// action, washes (`brandSoft` over `brandLine`) carry supporting information, +/// and everything else is warm neutral. There is no second hue competing for +/// meaning — `ok` survives only for the delivered state, `danger` only for +/// destructive text. +/// +/// **Manrope for voice, Geist Mono for data.** Headings run at w800 with tight +/// negative tracking; anything a customer might read back aloud or check +/// digit-by-digit — a booking reference, a phone number, a fare, a plate, a +/// countdown — is set in mono so the glyphs line up and a transposed character +/// is visible. +class DmColors { + DmColors._(); + + // ── Brand ──────────────────────────────────────────────────────────────── + /// The mark's single colour. + static const brand = Color(0xFF8F0F06); + + /// Pressed state, and crimson text sitting on a [brandSoft] wash where the + /// full brand would vibrate against the tint. + static const brandPress = Color(0xFF730C05); + static const brandDeep = brandPress; + + /// Tint behind selected options, wash cards and brand chips. + static const brandSoft = Color(0xFFFBF3F2); + + /// The wash's own edge, and the outline on quiet brand buttons. + static const brandLine = Color(0xFFF0DEDC); + + // ── Ink — warm neutrals, never pure black ──────────────────────────────── + static const ink = Color(0xFF191716); + static const ink2 = Color(0xFF5C5654); + static const ink3 = Color(0xFF6E6864); + static const ink4 = Color(0xFFB4AEAA); + + // ── Surfaces ───────────────────────────────────────────────────────────── + /// The page canvas. + static const canvas = Color(0xFFF7F6F4); + static const surface = Color(0xFFFFFFFF); + + /// Filled inputs, quiet chips, tiles, skeleton bases. Also the "sunken" + /// treatment for a disabled primary action. + static const surfaceAlt = Color(0xFFF1EFEC); + + /// Barely-there divider between rows inside one card. + static const hairline = Color(0xFFF1EFEC); + + /// A card's own edge — the workhorse of this design. + static const border = Color(0xFFE8E5E2); + + // ── On-brand text ──────────────────────────────────────────────────────── + /// Secondary text on a crimson field. Not white-at-opacity: a tinted rose + /// keeps its warmth where translucent white goes grey and dirty. + static const onBrand2 = Color(0xFFFFD9D4); + + /// Tertiary text on a crimson field. + static const onBrand3 = Color(0xFFFFD1CB); + + /// Recessed track inside the header (the segmented control's bed). + static const onBrandSunken = Color(0x24000000); + + /// A control resting on the crimson field. + static const onBrandRaise = Color(0x2EFFFFFF); + + // ── Semantic ───────────────────────────────────────────────────────────── + static const ok = Color(0xFF0A6B56); + static const okSoft = Color(0xFFE2F4EF); + static const danger = Color(0xFFB3231F); + + /// "You are here" on a map. Deliberately not the brand crimson — on a map + /// crimson means the pickup pin, and blue is the convention every customer + /// already reads as their own position. + static const locationDot = Color(0xFF1B6EF3); + static const locationHalo = Color(0x241B6EF3); + + static const scrim = Color(0x80191716); + + // Backwards-compatible aliases used across the widget layer. + static const bg = canvas; + static const surface2 = surfaceAlt; + static const line = hairline; + static const lineStrong = border; +} + +class DmRadius { + DmRadius._(); + + /// Tiles, chips, the small square that holds a glyph. + static const xs = 8.0; + + /// Buttons, fields, icon buttons. The design's most-used corner. + static const sm = 9.0; + + /// Cards and every panel that holds rows. + static const md = 10.0; + + /// Sheets' grabber area, the segmented control, the header's bottom sweep. + static const lg = 12.0; + + /// Bottom sheets and the auth sheet that overlaps the hero. + static const xl = 16.0; + + /// The brand header's bottom corners. + static const header = 18.0; + + static const pill = 999.0; + + /// Kept for call sites that mean "a primary bar's corner". + static const book = sm; + + static BorderRadius all(double r) => BorderRadius.circular(r); +} + +class DmSpace { + DmSpace._(); + + /// Horizontal page padding. 22 on the brand header's body, 24 on canvas + /// content — the header's crimson field reads as tighter than it measures, + /// so it takes the smaller inset. + static const pad = 24.0; + static const headerPad = 22.0; + + /// Minimum touch target. + static const tap = 48.0; + + /// Primary button height. + static const button = 54.0; + + /// Field height — matched to [button] so a form and its action share a rhythm. + static const field = 54.0; +} + +class DmShadow { + DmShadow._(); + + /// Under the one committing action on a screen, and under a selected slot. + static const lift = [ + BoxShadow( + color: Color(0x808F0F06), + blurRadius: 24, + spreadRadius: -12, + offset: Offset(0, 10), + ), + ]; + + /// A smaller version of [lift] for chips and half-width controls. + static const liftSm = [ + BoxShadow( + color: Color(0xA68F0F06), + blurRadius: 16, + spreadRadius: -8, + offset: Offset(0, 7), + ), + ]; + + /// Controls that genuinely float over content — map buttons, map chips. + static const float = [ + BoxShadow( + color: Color(0x0D191716), + blurRadius: 2, + offset: Offset(0, 1), + ), + ]; + + static const sheet = [ + BoxShadow( + color: Color(0x2E191716), + blurRadius: 40, + spreadRadius: -20, + offset: Offset(0, -8), + ), + ]; + + // Aliases kept for existing call sites. + static const two = float; + static const three = sheet; +} + +class DmMotion { + DmMotion._(); + static const fast = Duration(milliseconds: 140); + static const base = Duration(milliseconds: 250); + static const slow = Duration(milliseconds: 460); + static const ease = Curves.easeOutCubic; + + /// The bottom nav's travelling pill — a touch of overshoot, which reads as + /// the indicator catching up rather than teleporting. + static const glide = Cubic(0.34, 1.06, 0.36, 1); +} + +/// Manrope for voice, Geist Mono for data. +/// +/// Both ship as single variable-font files, which is why every style below sets +/// `fontVariations` as well as `fontWeight` — see the note inside the class. +class DmText { + DmText._(); + + static const _f = 'Manrope'; + static const _mono = 'GeistMono'; + + // The weight axis. Manrope and Geist Mono both ship as variable fonts, so a + // style has to move `wght` to change weight — `fontWeight` alone picks the + // nearest named instance and, on a single-file VF, that is always the + // default. Both are set on every style: the axis does the work, the enum + // keeps the semantics (and the system-font fallback) correct. + static const _w500 = [FontVariation('wght', 500)]; + static const _w600 = [FontVariation('wght', 600)]; + static const _w700 = [FontVariation('wght', 700)]; + static const _w800 = [FontVariation('wght', 800)]; + + // Letter spacing is quoted in em by the design and converted here at each + // size, because -0.035em is a different number of pixels on a 31pt display + // head than on a 15pt row title, and splitting the difference would soften + // exactly the heads that are supposed to feel tight. + + // ── Display ────────────────────────────────────────────────────────────── + + /// Screen-dominating statement — "Pickup booked", "Arun is 12 minutes away". + static const display = TextStyle( + fontFamily: _f, fontSize: 31, height: 1.12, + fontWeight: FontWeight.w800, fontVariations: _w800, + letterSpacing: -1.09, color: DmColors.ink, + ); + + /// The brand header's own title, sitting on crimson. + static const headerTitle = TextStyle( + fontFamily: _f, fontSize: 34, height: 1.0, + fontWeight: FontWeight.w800, fontVariations: _w800, + letterSpacing: -1.36, color: Colors.white, + ); + + /// Screen headings on a canvas. + static const title = TextStyle( + fontFamily: _f, fontSize: 25, height: 1.18, + fontWeight: FontWeight.w800, fontVariations: _w800, + letterSpacing: -0.75, color: DmColors.ink, + ); + + /// Tab-root page titles where no brand header is used. + static const pageTitle = title; + + /// The heading above a group of cards, and a sheet's own title. + static const heading = TextStyle( + fontFamily: _f, fontSize: 18, height: 1.25, + fontWeight: FontWeight.w700, fontVariations: _w700, + letterSpacing: -0.45, color: DmColors.ink, + ); + + static const sheetTitle = TextStyle( + fontFamily: _f, fontSize: 19, height: 1.2, + fontWeight: FontWeight.w800, fontVariations: _w800, + letterSpacing: -0.57, color: DmColors.ink, + ); + + // ── Rows ───────────────────────────────────────────────────────────────── + + /// A row's own line — `t1` in the design, and the workhorse of the app. + static const cardTitle = TextStyle( + fontFamily: _f, fontSize: 15, height: 1.4, + fontWeight: FontWeight.w700, fontVariations: _w700, + letterSpacing: -0.18, color: DmColors.ink, + ); + + /// Alias: the same weight of statement, used mid-form. + static const bodyStrong = cardTitle; + + static const body = TextStyle( + fontFamily: _f, fontSize: 15, height: 1.5, + fontWeight: FontWeight.w500, fontVariations: _w500, + letterSpacing: -0.12, color: DmColors.ink, + ); + + /// Supporting line under a row title — `t2`. + static const small = TextStyle( + fontFamily: _f, fontSize: 13.5, height: 1.45, + fontWeight: FontWeight.w500, fontVariations: _w500, + color: DmColors.ink2, + ); + + /// The quietest line — `t3`. Timestamps, counts, "collected on". + static const tiny = TextStyle( + fontFamily: _f, fontSize: 13, height: 1.4, + fontWeight: FontWeight.w500, fontVariations: _w500, + color: DmColors.ink3, + ); + + /// Paragraph under a screen heading. + static const lede = TextStyle( + fontFamily: _f, fontSize: 14.5, height: 1.55, + fontWeight: FontWeight.w500, fontVariations: _w500, + color: DmColors.ink2, + ); + + /// The right-hand value in a settings row. + static const value = TextStyle( + fontFamily: _f, fontSize: 13.5, height: 1.4, + fontWeight: FontWeight.w600, fontVariations: _w600, + color: DmColors.ink2, + ); + + /// Field labels, tags, chips, inline actions. + static const label = TextStyle( + fontFamily: _f, fontSize: 12.5, height: 1.3, + fontWeight: FontWeight.w700, fontVariations: _w700, + color: DmColors.ink2, + ); + + static const button = TextStyle( + fontFamily: _f, fontSize: 15.5, height: 1.2, + fontWeight: FontWeight.w700, fontVariations: _w700, + letterSpacing: -0.23, + ); + + /// The text-only action under a primary button ("Back to home"). + static const textButton = TextStyle( + fontFamily: _f, fontSize: 14.5, height: 1.2, + fontWeight: FontWeight.w700, fontVariations: _w700, + color: DmColors.ink2, + ); + + /// Uppercase micro-label above a group — the design's `micro`. Crimson when + /// it heads brand content, [DmColors.ink3] when it heads a neutral group. + static const eyebrow = TextStyle( + fontFamily: _f, fontSize: 10.5, height: 1.3, + fontWeight: FontWeight.w700, fontVariations: _w700, + letterSpacing: 1.16, color: DmColors.ink3, + ); + + /// The eyebrow as it appears on the crimson header. + static const headerEyebrow = TextStyle( + fontFamily: _f, fontSize: 11.5, height: 1.3, + fontWeight: FontWeight.w800, fontVariations: _w800, + letterSpacing: 1.84, color: DmColors.onBrand3, + ); + + /// Sentence-case heading above a group of rows. + static const sectionHead = TextStyle( + fontFamily: _f, fontSize: 15, height: 1.3, + fontWeight: FontWeight.w700, fontVariations: _w700, + letterSpacing: -0.3, color: DmColors.ink, + ); + + /// Alias kept for existing call sites. + static const groupHead = sectionHead; + + /// Field input text. + static const field = TextStyle( + fontFamily: _f, fontSize: 16, height: 1.3, + fontWeight: FontWeight.w600, fontVariations: _w600, + color: DmColors.ink, + ); + + // ── Mono: anything checkable digit by digit ────────────────────────────── + + /// References, plates and phone numbers sitting inline in a row. + static const mono = TextStyle( + fontFamily: _mono, fontSize: 13, height: 1.4, + fontWeight: FontWeight.w500, fontVariations: _w500, + letterSpacing: -0.26, color: DmColors.ink3, + ); + + /// Step counters, timers, the smallest data. + static const monoSm = TextStyle( + fontFamily: _mono, fontSize: 11.5, height: 1.3, + fontWeight: FontWeight.w500, fontVariations: _w500, + letterSpacing: -0.23, color: DmColors.ink3, + ); + + /// A booking reference given its own line, or a fare. + static const monoLg = TextStyle( + fontFamily: _mono, fontSize: 19, height: 1.2, + fontWeight: FontWeight.w500, fontVariations: _w500, + letterSpacing: -0.38, color: DmColors.ink, + ); + + /// The OTP digits. + static const otp = TextStyle( + fontFamily: _f, fontSize: 24, height: 1.1, + fontWeight: FontWeight.w700, fontVariations: _w700, + color: DmColors.ink, + ); +} diff --git a/lib/ui/widgets/buttons.dart b/lib/ui/widgets/buttons.dart new file mode 100644 index 0000000..b8dca95 --- /dev/null +++ b/lib/ui/widgets/buttons.dart @@ -0,0 +1,375 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../tokens.dart'; + +enum DmButtonKind { + /// Crimson, with the glow. The one committing action on a screen. + primary, + + /// White with a hairline edge. A real action that is not *the* action — + /// "Share live tracking". + outline, + + /// White with a crimson edge and crimson label. Brand-weighted, but not a + /// commitment — used where an outline would read as too neutral. + quiet, + + /// A crimson wash. Rare; secondary actions inside a wash card. + soft, + + /// No surface at all. The line under a primary action. + ghost, +} + +/// The app's only button. +/// +/// `block: true` fills the width (the default in footers). The disabled primary +/// does not fade the crimson — it goes to the sunken neutral, because a +/// half-opacity red still reads as "press me" and this design uses exactly one +/// red for exactly one meaning. +class DmButton extends StatefulWidget { + const DmButton({ + super.key, + required this.label, + this.onPressed, + this.kind = DmButtonKind.primary, + this.block = true, + this.danger = false, + this.busy = false, + this.busyLabel, + this.icon, + this.iconLeading = false, + this.height, + }); + + final String label; + final VoidCallback? onPressed; + final DmButtonKind kind; + final bool block; + + /// Renders the label in the danger colour (Cancel pickup). + final bool danger; + final bool busy; + final String? busyLabel; + + /// Optional icon, e.g. an arrow on a forward action. + final IconData? icon; + + /// Puts [icon] before the label. True for a pair of short actions — "Call", + /// "Message" — where the glyph is what the eye lands on first. + final bool iconLeading; + + /// Overrides [DmSpace.button] — for the half-width pair inside a card. + final double? height; + + @override + State createState() => _DmButtonState(); +} + +class _DmButtonState extends State { + bool _down = false; + + bool get _enabled => widget.onPressed != null && !widget.busy; + + @override + Widget build(BuildContext context) { + final Color background; + final Color foreground; + BoxBorder? border; + List? shadow; + double height = widget.height ?? DmSpace.button; + + switch (widget.kind) { + case DmButtonKind.primary: + background = !_enabled + ? DmColors.surfaceAlt + : (_down ? DmColors.brandPress : DmColors.brand); + foreground = _enabled ? Colors.white : DmColors.ink3; + shadow = _enabled && !_down ? DmShadow.lift : null; + case DmButtonKind.outline: + background = _down ? DmColors.surfaceAlt : DmColors.surface; + foreground = widget.danger ? DmColors.danger : DmColors.ink; + border = Border.all(color: DmColors.border); + case DmButtonKind.quiet: + background = _down ? DmColors.brandSoft : DmColors.surface; + foreground = DmColors.brand; + border = Border.all(color: DmColors.brandLine); + case DmButtonKind.soft: + background = _down ? DmColors.brandLine : DmColors.brandSoft; + foreground = DmColors.brand; + case DmButtonKind.ghost: + background = Colors.transparent; + foreground = widget.danger ? DmColors.brand : DmColors.ink2; + height = widget.height ?? DmSpace.tap; + } + + final content = Row( + mainAxisSize: widget.block ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.icon != null && widget.iconLeading && !widget.busy) ...[ + Icon(widget.icon, size: 18, color: foreground), + const SizedBox(width: 9), + ], + if (widget.busy) ...[ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + strokeCap: StrokeCap.round, + valueColor: AlwaysStoppedAnimation(foreground), + ), + ), + const SizedBox(width: 10), + ], + Flexible( + child: Text( + widget.busy ? (widget.busyLabel ?? widget.label) : widget.label, + style: (widget.kind == DmButtonKind.ghost + ? DmText.textButton + : DmText.button) + .copyWith(color: foreground), + textAlign: TextAlign.center, + ), + ), + if (widget.icon != null && !widget.iconLeading && !widget.busy) ...[ + const SizedBox(width: 9), + Icon(widget.icon, size: 18, color: foreground), + ], + ], + ); + + // Presses translate rather than scale: the design's buttons sit on a + // hairline grid, and scaling a 1px border makes it shimmer. + final button = AnimatedSlide( + offset: _down && _enabled ? const Offset(0, 0.018) : Offset.zero, + duration: DmMotion.fast, + curve: DmMotion.ease, + child: AnimatedContainer( + duration: DmMotion.fast, + constraints: BoxConstraints(minHeight: height), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: background, + border: border, + borderRadius: DmRadius.all(DmRadius.sm), + boxShadow: shadow, + ), + alignment: Alignment.center, + child: content, + ), + ); + + return Semantics( + button: true, + enabled: _enabled, + label: widget.label, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: _enabled ? (_) => setState(() => _down = true) : null, + onTapUp: _enabled ? (_) => setState(() => _down = false) : null, + onTapCancel: _enabled ? () => setState(() => _down = false) : null, + onTap: _enabled + ? () { + HapticFeedback.selectionClick(); + widget.onPressed!(); + } + : null, + child: widget.block ? SizedBox(width: double.infinity, child: button) : button, + ), + ); + } +} + +/// Half-width action inside a card — Call / Message on the rider row. +class DmMiniButton extends StatelessWidget { + const DmMiniButton({ + super.key, + required this.label, + required this.icon, + this.onPressed, + this.filled = false, + }); + + final String label; + final IconData icon; + final VoidCallback? onPressed; + + /// The one of the pair that is the expected action. + final bool filled; + + @override + Widget build(BuildContext context) { + return Expanded( + child: DmButton( + label: label, + icon: icon, + iconLeading: true, + height: 42, + kind: filled ? DmButtonKind.primary : DmButtonKind.outline, + onPressed: onPressed, + ), + ); + } +} + +/// Small quiet pill inside a card ("Change", when it needs a surface). +class DmChipButton extends StatelessWidget { + const DmChipButton({super.key, required this.label, this.onPressed, this.icon}); + + final String label; + final VoidCallback? onPressed; + final IconData? icon; + + @override + Widget build(BuildContext context) { + return Material( + color: DmColors.surfaceAlt, + borderRadius: DmRadius.all(DmRadius.xs), + child: InkWell( + onTap: onPressed, + borderRadius: DmRadius.all(DmRadius.xs), + child: Container( + constraints: const BoxConstraints(minHeight: 34), + padding: EdgeInsets.only( + left: icon == null ? 12 : 9, + right: 12, + top: 6, + bottom: 6, + ), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: DmColors.ink2), + const SizedBox(width: 5), + ], + Text(label, style: DmText.label), + ], + ), + ), + ), + ); + } +} + +/// Text-only action — the "Change" / "Edit" / "Copy" at the end of a card row. +/// +/// Its padding is negative-margined back out, so the label sits flush with the +/// card's right edge while the tap target still clears 40pt. +class DmTextAction extends StatelessWidget { + const DmTextAction({ + super.key, + required this.label, + this.onPressed, + this.color = DmColors.brand, + this.minHeight = 40, + this.icon, + }); + + final String label; + final VoidCallback? onPressed; + final Color color; + final double minHeight; + final IconData? icon; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onPressed == null + ? null + : () { + HapticFeedback.selectionClick(); + onPressed!(); + }, + child: Container( + constraints: BoxConstraints(minHeight: minHeight), + padding: const EdgeInsets.symmetric(horizontal: 6), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + ], + // Flexible, because this sits at the end of rows whose other half + // is an address or a window that has first claim on the width. + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.label.copyWith(fontSize: 13, color: color), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// The chrome affordance: a rounded square, not a disc. +class DmIconButton extends StatelessWidget { + const DmIconButton({ + super.key, + required this.icon, + this.onPressed, + this.background = DmColors.surface, + this.foreground = DmColors.ink, + this.border = DmColors.border, + this.size = 40, + this.radius = DmRadius.sm, + this.tooltip, + }); + + final IconData icon; + final VoidCallback? onPressed; + final Color background; + final Color foreground; + + /// Transparent on the crimson header, where the fill carries the shape. + final Color border; + final double size; + final double radius; + final String? tooltip; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: tooltip, + child: SizedBox( + width: DmSpace.tap, + height: DmSpace.tap, + child: Center( + child: Material( + color: background, + borderRadius: DmRadius.all(radius), + child: InkWell( + borderRadius: DmRadius.all(radius), + onTap: onPressed, + child: Ink( + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: DmRadius.all(radius), + border: Border.all(color: border), + ), + child: Icon(icon, size: 19, color: foreground), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/widgets/cards.dart b/lib/ui/widgets/cards.dart new file mode 100644 index 0000000..7d02b16 --- /dev/null +++ b/lib/ui/widgets/cards.dart @@ -0,0 +1,470 @@ +import 'package:flutter/material.dart'; + +import '../tokens.dart'; + +/// How loudly a card speaks. +enum DmCardTone { + /// White on the canvas, hairline edge. Almost everything. + plain, + + /// Filled crimson. **One per screen, at most** — it is the screen's answer + /// to "what is happening right now": the live ETA, the booked confirmation, + /// the delivered summary. A second one on the same screen and neither wins. + fill, + + /// A crimson wash. Supporting brand information that must not shout: the + /// address being collected from, the rider on the way, a reassurance. + wash, + + /// The canvas itself, edged. For content that is deliberately inert — the + /// build panel on Account. + sunken, +} + +/// A bordered panel that stacks rows and rules them apart. +/// +/// This replaces the shadowed card the app used to draw everywhere. The rule +/// between rows belongs to the card, not to the rows: a row does not know +/// whether it is first, and a card that is handed one child draws no rule at +/// all, so the same [DmCardCell] works alone or in a stack of five. +class DmCard extends StatelessWidget { + const DmCard({ + super.key, + required this.children, + this.tone = DmCardTone.plain, + this.rail = false, + this.onTap, + }); + + final List children; + final DmCardTone tone; + + /// A 3pt crimson spine down the leading edge — marks the one row in a list + /// that needs the customer's attention now. + final bool rail; + + final VoidCallback? onTap; + + Color get _background => switch (tone) { + DmCardTone.plain => DmColors.surface, + DmCardTone.fill => DmColors.brand, + DmCardTone.wash => DmColors.brandSoft, + DmCardTone.sunken => DmColors.canvas, + }; + + Color get _edge => switch (tone) { + DmCardTone.plain => DmColors.border, + DmCardTone.fill => DmColors.brand, + DmCardTone.wash => DmColors.brandLine, + DmCardTone.sunken => DmColors.border, + }; + + /// The rule between two rows of the same card. On crimson it is a lightened + /// white rather than a darker line: a dark rule on a saturated field reads as + /// a crack, a light one as a seam. + Color get _rule => switch (tone) { + DmCardTone.plain => DmColors.hairline, + DmCardTone.fill => const Color(0x2EFFFFFF), + DmCardTone.wash => DmColors.brandLine, + DmCardTone.sunken => DmColors.border, + }; + + @override + Widget build(BuildContext context) { + // The edge is drawn in the FOREGROUND, over the children. + // + // As a background border it was painted first and then covered: a cell with + // its own tint — the washed status strip at the head of an order card, the + // crimson rows on the booked screen — filled the whole box, border included, + // and the card lost its outline exactly where it needed one most. A + // foreground decoration paints last, so every cell is clipped inside an + // edge that is always visible. + final card = DecoratedBox( + decoration: BoxDecoration( + color: _background, + borderRadius: DmRadius.all(DmRadius.md), + ), + child: Container( + foregroundDecoration: BoxDecoration( + borderRadius: DmRadius.all(DmRadius.md), + border: Border.all(color: _edge), + ), + child: ClipRRect( + borderRadius: DmRadius.all(DmRadius.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) Container(height: 1, color: _rule), + children[i], + ], + ], + ), + ), + ), + ); + + final railed = rail + ? Container( + decoration: BoxDecoration( + color: DmColors.brand, + borderRadius: DmRadius.all(DmRadius.md), + ), + padding: const EdgeInsets.only(left: 3), + child: card, + ) + : card; + + if (onTap == null) return railed; + return Semantics( + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: railed, + ), + ); + } +} + +/// One padded cell inside a [DmCard] — the design's `.p`. +class DmCardCell extends StatelessWidget { + const DmCardCell({ + super.key, + required this.child, + this.padding = const EdgeInsets.fromLTRB(16, 15, 16, 15), + this.background, + this.onTap, + }); + + final Widget child; + final EdgeInsets padding; + + /// Tints this cell alone — a wash strip at the head of a plain card. + final Color? background; + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final body = Padding(padding: padding, child: child); + final painted = background == null + ? body + : ColoredBox(color: background!, child: body); + + if (onTap == null) return painted; + return Material( + color: background ?? Colors.transparent, + child: InkWell( + onTap: onTap, + splashColor: DmColors.brandSoft, + highlightColor: DmColors.brandSoft.withValues(alpha: 0.6), + child: body, + ), + ); + } +} + +/// The common cell: a glyph or tile, a title with an optional second line, and +/// something on the right — a value, a chevron, an inline action. +class DmCardRow extends StatelessWidget { + const DmCardRow({ + super.key, + required this.title, + this.eyebrow, + this.subtitle, + this.leading, + this.trailing, + this.tone = DmCardTone.plain, + this.onTap, + this.titleStyle, + this.subtitleStyle, + this.background, + this.crossAlign = CrossAxisAlignment.center, + }); + + final String title; + + /// A micro-label above the title — "COLLECTING FROM". + final String? eyebrow; + final String? subtitle; + final Widget? leading; + final Widget? trailing; + + /// The tone of the *card this row sits in*, so the row can pick text colours + /// that survive a crimson field. + final DmCardTone tone; + + final VoidCallback? onTap; + final TextStyle? titleStyle; + final TextStyle? subtitleStyle; + final Color? background; + final CrossAxisAlignment crossAlign; + + @override + Widget build(BuildContext context) { + final onFill = tone == DmCardTone.fill; + final titleColor = onFill ? Colors.white : DmColors.ink; + final subColor = switch (tone) { + DmCardTone.fill => DmColors.onBrand2, + DmCardTone.wash => DmColors.brandPress, + _ => DmColors.ink2, + }; + final eyebrowColor = switch (tone) { + DmCardTone.fill => DmColors.onBrand3, + DmCardTone.wash => DmColors.brand, + _ => DmColors.ink3, + }; + + return DmCardCell( + background: background, + onTap: onTap, + child: Row( + crossAxisAlignment: crossAlign, + children: [ + if (leading != null) ...[leading!, const SizedBox(width: 12)], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (eyebrow != null) ...[ + Text( + eyebrow!.toUpperCase(), + style: DmText.eyebrow.copyWith(color: eyebrowColor), + ), + const SizedBox(height: 3), + ], + Text( + title, + style: (titleStyle ?? DmText.cardTitle).copyWith( + color: titleStyle?.color ?? titleColor, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 3), + Text( + subtitle!, + style: (subtitleStyle ?? DmText.small).copyWith( + color: subtitleStyle?.color ?? subColor, + ), + ), + ], + ], + ), + ), + if (trailing != null) ...[const SizedBox(width: 12), trailing!], + ], + ), + ); + } +} + +/// How a [DmTile] is filled. +enum DmTileTone { sunken, wash, brand, onFill } + +/// The 38pt rounded square that holds a glyph or a step number. +/// +/// Square, not a circle: the cards, fields and buttons around it are all cut +/// with the same small radius, and a disc in the middle of that is the one +/// shape that has to be explained. +class DmTile extends StatelessWidget { + const DmTile({ + super.key, + this.icon, + this.label, + this.tone = DmTileTone.sunken, + this.size = 38, + }) : assert(icon != null || label != null, 'a tile shows a glyph or a number'); + + final IconData? icon; + + /// A step number, for the "how it works" sequence. + final String? label; + final DmTileTone tone; + final double size; + + @override + Widget build(BuildContext context) { + final (bg, fg) = switch (tone) { + DmTileTone.sunken => (DmColors.surfaceAlt, DmColors.ink), + DmTileTone.wash => (DmColors.brandSoft, DmColors.brand), + DmTileTone.brand => (DmColors.brand, Colors.white), + DmTileTone.onFill => (DmColors.onBrandRaise, Colors.white), + }; + + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: bg, + borderRadius: DmRadius.all(DmRadius.xs), + ), + child: icon != null + ? Icon(icon, size: size * 0.47, color: fg) + : Text( + label!, + style: DmText.label.copyWith( + fontSize: 12.5, + fontWeight: FontWeight.w800, + fontVariations: const [FontVariation('wght', 800)], + color: fg, + ), + ), + ); + } +} + +/// A figure and its caption, sat in its own bordered box. Used in pairs. +class DmMetric extends StatelessWidget { + const DmMetric({ + super.key, + required this.figure, + required this.caption, + this.tone = DmCardTone.plain, + }); + + final String figure; + final String caption; + final DmCardTone tone; + + @override + Widget build(BuildContext context) { + final (bg, edge, figureColor, captionColor) = switch (tone) { + DmCardTone.fill => ( + DmColors.brand, + DmColors.brand, + Colors.white, + DmColors.onBrand3, + ), + DmCardTone.wash => ( + DmColors.brandSoft, + DmColors.brandLine, + DmColors.brand, + DmColors.brandPress, + ), + _ => (DmColors.surface, DmColors.border, DmColors.ink, DmColors.ink3), + }; + + return Container( + padding: const EdgeInsets.fromLTRB(15, 14, 15, 14), + decoration: BoxDecoration( + color: bg, + border: Border.all(color: edge), + borderRadius: DmRadius.all(DmRadius.md), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + figure, + style: DmText.display.copyWith(fontSize: 25, height: 1, color: figureColor), + ), + const SizedBox(height: 5), + Text( + caption, + style: DmText.value.copyWith(fontSize: 12.5, color: captionColor), + ), + ], + ), + ); + } +} + +/// A label and its figure on one baseline — the fare breakdown, the build +/// panel. The label may wrap; the figure never does. +class DmFareLine extends StatelessWidget { + const DmFareLine({ + super.key, + required this.label, + required this.value, + this.strong = false, + this.mono = true, + }); + + final String label; + final String value; + + /// The total, rather than one of the lines that make it up. + final bool strong; + + /// Figures are mono by default — they are meant to be compared down a column. + final bool mono; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Text( + label, + style: DmText.small.copyWith( + fontWeight: strong ? FontWeight.w600 : FontWeight.w500, + ), + ), + ), + const SizedBox(width: 12), + Text( + value, + maxLines: 1, + style: mono + ? DmText.mono.copyWith( + fontSize: strong ? 15 : 13.5, + color: strong ? DmColors.brand : DmColors.ink, + ) + : DmText.value.copyWith( + color: strong ? DmColors.brand : DmColors.ink, + ), + ), + ], + ), + ); + } +} + +/// The uppercase micro-label that heads a group of cards. +class DmMicroHead extends StatelessWidget { + const DmMicroHead( + this.text, { + super.key, + this.brand = true, + this.top = 20, + this.bottom = 10, + this.trailing, + }); + + final String text; + + /// Crimson heads brand content; neutral heads everything else. + final bool brand; + final double top; + final double bottom; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(top: top, bottom: bottom), + child: Row( + children: [ + Expanded( + child: Text( + text.toUpperCase(), + style: DmText.eyebrow.copyWith( + color: brand ? DmColors.brand : DmColors.ink3, + ), + ), + ), + ?trailing, + ], + ), + ); + } +} diff --git a/lib/ui/widgets/chrome.dart b/lib/ui/widgets/chrome.dart new file mode 100644 index 0000000..b585f20 --- /dev/null +++ b/lib/ui/widgets/chrome.dart @@ -0,0 +1,655 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../data/app_config.dart'; +import '../tokens.dart'; +import 'buttons.dart'; + +/// Top bar for pushed screens: a square back affordance, a quiet title, an +/// optional trailing slot (usually the booking reference in mono), and the +/// booking step indicator. +class DmTopBar extends StatelessWidget implements PreferredSizeWidget { + const DmTopBar({ + super.key, + this.title, + this.trailing, + this.progress, + this.steps, + this.stepLabel, + this.onBack, + this.background = DmColors.canvas, + }); + + final String? title; + final Widget? trailing; + + /// 0..1 — omit to hide the step indicator. + final double? progress; + + /// When given, the indicator is drawn as [steps] separate segments rather + /// than one continuous rail. Discrete steps are countable; a filled bar is + /// only a proportion, and "how many screens until I am done" is the question + /// a customer is actually asking. + final int? steps; + + /// The line above the segments — "Step 2 of 4 · Destination". + final String? stepLabel; + + final VoidCallback? onBack; + final Color background; + + @override + Size get preferredSize => Size.fromHeight(56 + (progress == null ? 0 : 42)); + + @override + Widget build(BuildContext context) { + final current = progress == null || steps == null + ? 0 + : (progress! * steps!).round().clamp(0, steps!); + + return Material( + color: background, + child: SafeArea( + bottom: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 56, + child: Row( + children: [ + const SizedBox(width: DmSpace.pad - 12), + DmIconButton( + icon: LucideIcons.arrowLeft, + tooltip: 'Back', + onPressed: onBack ?? () => Navigator.of(context).maybePop(), + ), + const SizedBox(width: 6), + Expanded( + child: title == null + ? const SizedBox.shrink() + : Text( + title!, + style: DmText.cardTitle.copyWith( + fontSize: 15.5, + letterSpacing: -0.31, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ?trailing, + const SizedBox(width: DmSpace.pad), + ], + ), + ), + if (progress != null) + Padding( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16), + child: DmStepBar( + progress: progress!, + steps: steps, + current: current, + label: stepLabel, + ), + ), + ], + ), + ), + ); + } +} + +/// Booking progress. +/// +/// With [steps] it draws that many segments, captioned "Step n of m" in mono; +/// without, it falls back to a single filled rail for callers that only know a +/// fraction. +class DmStepBar extends StatelessWidget { + const DmStepBar({ + super.key, + required this.progress, + this.steps, + this.current = 0, + this.label, + this.onFill = false, + }); + + final double progress; + final int? steps; + final int current; + final String? label; + + /// Drawn on a crimson field (the live-tracking card), where the track is a + /// lightened white and the fill is solid white. + final bool onFill; + + @override + Widget build(BuildContext context) { + final track = onFill ? const Color(0x52FFFFFF) : DmColors.border; + final fill = onFill ? Colors.white : DmColors.brand; + + final bar = steps == null + ? ClipRRect( + borderRadius: DmRadius.all(DmRadius.pill), + child: SizedBox( + height: 3, + child: Stack( + children: [ + Positioned.fill(child: ColoredBox(color: track)), + FractionallySizedBox( + widthFactor: progress.clamp(0, 1), + child: AnimatedContainer( + duration: DmMotion.slow, + curve: DmMotion.ease, + decoration: BoxDecoration( + color: fill, + borderRadius: DmRadius.all(DmRadius.pill), + ), + ), + ), + ], + ), + ), + ) + : Row( + children: [ + for (var i = 0; i < steps!; i++) ...[ + if (i > 0) const SizedBox(width: 4), + Expanded( + child: AnimatedContainer( + duration: DmMotion.base, + curve: DmMotion.ease, + height: 3, + decoration: BoxDecoration( + color: i < current ? fill : track, + borderRadius: DmRadius.all(DmRadius.pill), + ), + ), + ), + ], + ], + ); + + if (label == null && steps == null) return bar; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null || steps != null) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (steps != null) + Text.rich( + TextSpan( + style: DmText.monoSm.copyWith( + color: onFill ? DmColors.onBrand3 : DmColors.ink3, + ), + children: [ + const TextSpan(text: 'Step '), + TextSpan( + text: '$current', + style: TextStyle(color: onFill ? Colors.white : DmColors.brand), + ), + TextSpan(text: ' of ${steps!}'), + ], + ), + ), + const Spacer(), + if (label != null) + Flexible( + child: Text( + label!, + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.label.copyWith( + fontSize: 12.5, + color: onFill ? DmColors.onBrand3 : DmColors.ink, + ), + ), + ), + ], + ), + const SizedBox(height: 9), + ], + bar, + ], + ); + } +} + +/// Sticky bottom action area. +/// +/// `edge: true` lifts it onto white with a hairline above — used when the +/// content behind it scrolls, so the action never looks like the end of the +/// list. On a short screen the canvas variant keeps the page quiet. +class DmFooter extends StatelessWidget { + const DmFooter({ + super.key, + required this.children, + this.edge = false, + this.background, + this.divider = false, + }); + + final List children; + + /// White bed, hairline above. + final bool edge; + + final Color? background; + + /// Retained for call-site compatibility; superseded by [edge]. + final bool divider; + + @override + Widget build(BuildContext context) { + final lifted = edge || divider; + return DecoratedBox( + decoration: BoxDecoration( + color: background ?? (lifted ? DmColors.surface : DmColors.canvas), + border: lifted + ? const Border(top: BorderSide(color: DmColors.border)) + : null, + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) const SizedBox(height: 4), + children[i], + ], + ], + ), + ), + ), + ); + } +} + +/// The crimson field at the top of a tab root. +/// +/// Home, Orders and Account all open with one. It does three jobs at once — +/// it states where you are (eyebrow + title), it holds that destination's one +/// control (a segmented filter, an address switcher, a phone number), and it +/// gives the app a recognisable brand surface that the white canvas below can +/// then stay completely quiet. +/// +/// The arcs are drawn, not an asset: they are struck from the same geometry as +/// the mark, they scale to any header height, and they cost no bytes. +class DmBrandHeader extends StatelessWidget { + const DmBrandHeader({ + super.key, + required this.title, + this.eyebrow, + this.leading, + this.action, + this.child, + }); + + final String title; + final String? eyebrow; + + /// Sits at the top-left of the header — the avatar on Account. + final Widget? leading; + + /// Top-right — refresh, help. + final Widget? action; + + /// The control that belongs to this destination, under the title. + final Widget? child; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(DmRadius.header), + ), + child: ColoredBox( + color: DmColors.brand, + child: CustomPaint( + painter: const DmArcField(), + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + DmSpace.headerPad, + 2, + DmSpace.headerPad, + 18, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 48, + child: Row( + children: [ + ?leading, + const Spacer(), + // ── A build on dev data says so, on every screen ── + // + // Twice now a booking made offline has been hunted for + // in the admin console. The Account screen named the + // mode, but nobody goes to Account before booking. It + // belongs where the booking is made. + if (AppConfig.useDevData) ...[ + Container( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + decoration: BoxDecoration( + border: Border.all(color: const Color(0x80FFFFFF)), + borderRadius: DmRadius.all(5), + ), + child: Text( + 'OFFLINE DATA', + style: DmText.eyebrow.copyWith( + fontSize: 10, + color: Colors.white, + letterSpacing: 1.2, + ), + ), + ), + const SizedBox(width: 10), + ], + ?action, + ], + ), + ), + const SizedBox(height: 10), + if (eyebrow != null) ...[ + Text(eyebrow!.toUpperCase(), style: DmText.headerEyebrow), + const SizedBox(height: 8), + ], + Text(title, style: DmText.headerTitle), + if (child != null) ...[const SizedBox(height: 18), child!], + ], + ), + ), + ), + ), + ), + ); + } +} + +/// Thin arc strokes struck from the mark's geometry, at the opacity the design +/// uses. Decorative only — it never carries meaning, so it is not announced. +class DmArcField extends CustomPainter { + const DmArcField({this.opacity = 0.15}); + + final double opacity; + + @override + void paint(Canvas canvas, Size size) { + // The arcs are authored on a 340-square field anchored to the top-right, + // then scaled — so the same geometry reads identically on a 210pt Orders + // header and a 260pt Account one. + const field = 340.0; + final scale = size.width / 390 * 1.0; + canvas.save(); + canvas.translate(size.width - field * scale + 62 * scale, -72 * scale); + canvas.scale(scale); + + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5 / scale + ..color = Colors.white.withValues(alpha: opacity); + + canvas.drawCircle(const Offset(250, 90), 120, paint); + canvas.drawCircle(const Offset(250, 90), 170, paint); + + for (final (y1, y2, cx, cy) in const [ + (300.0, 40.0, 60.0, 300.0), + (330.0, 60.0, 70.0, 330.0), + ]) { + final path = Path() + ..moveTo(-20, y1) + ..cubicTo(cx, cy, field - 40, y2 + 140, field, y2); + canvas.drawPath(path, paint); + } + + canvas.restore(); + } + + @override + bool shouldRepaint(covariant DmArcField oldDelegate) => + oldDelegate.opacity != opacity; +} + +/// The segmented filter recessed into the brand header — Orders' Active / +/// Completed / Cancelled, with each tab's count. +/// +/// The selected tab lifts out as a white pill and keeps its figure; the others +/// stay in the recess. One control, three states, no separate count row. +class DmSegmented extends StatelessWidget { + const DmSegmented({ + super.key, + required this.labels, + required this.counts, + required this.index, + required this.onChanged, + }); + + final List labels; + + /// Null where a tab has no meaningful figure yet (still loading). + final List counts; + final int index; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: DmColors.onBrandSunken, + borderRadius: DmRadius.all(DmRadius.lg), + ), + child: Row( + children: [ + for (var i = 0; i < labels.length; i++) ...[ + if (i > 0) const SizedBox(width: 4), + Expanded( + child: _Segment( + label: labels[i], + count: i < counts.length ? counts[i] : null, + selected: i == index, + onTap: () => onChanged(i), + ), + ), + ], + ], + ), + ); + } +} + +class _Segment extends StatelessWidget { + const _Segment({ + required this.label, + required this.count, + required this.selected, + required this.onTap, + }); + + final String label; + final int? count; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + selected: selected, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + HapticFeedback.selectionClick(); + onTap(); + }, + child: AnimatedContainer( + duration: DmMotion.base, + curve: DmMotion.ease, + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11), + decoration: BoxDecoration( + color: selected ? Colors.white : Colors.transparent, + borderRadius: DmRadius.all(DmRadius.sm), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.label.copyWith( + fontSize: 13, + color: selected ? DmColors.brand : DmColors.onBrand3, + ), + ), + ), + if (count != null) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: selected ? DmColors.brandSoft : DmColors.onBrandRaise, + borderRadius: DmRadius.all(DmRadius.pill), + ), + child: Text( + '$count', + style: DmText.monoSm.copyWith( + fontSize: 11, + color: selected ? DmColors.brand : Colors.white, + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +/// The recessed strip under a header title that carries one live fact — the +/// address a pickup is coming from, or the customer's phone number. +class DmHeaderTrack extends StatelessWidget { + const DmHeaderTrack({ + super.key, + required this.child, + this.leading, + this.trailing, + this.onTap, + }); + + final Widget child; + final Widget? leading; + final Widget? trailing; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: onTap != null, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + padding: const EdgeInsets.fromLTRB(14, 13, 14, 13), + decoration: BoxDecoration( + color: DmColors.onBrandSunken, + borderRadius: DmRadius.all(DmRadius.lg), + ), + child: Row( + children: [ + if (leading != null) ...[leading!, const SizedBox(width: 10)], + Expanded(child: child), + if (trailing != null) ...[const SizedBox(width: 10), trailing!], + ], + ), + ), + ), + ); + } +} + +/// Large title for a screen that has no brand header. +class DmPageTitle extends StatelessWidget { + const DmPageTitle(this.text, {super.key, this.subtitle}); + + final String text; + final String? subtitle; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 24, DmSpace.pad, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(text, style: DmText.pageTitle), + if (subtitle != null) ...[ + const SizedBox(height: 6), + Text(subtitle!, style: DmText.lede), + ], + ], + ), + ); + } +} + +/// Sentence-case heading above a group of fields or rows. +class DmGroupHead extends StatelessWidget { + const DmGroupHead(this.text, {super.key, this.top = 24}); + + final String text; + final double top; + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(top: top, bottom: 10), + child: Text(text, style: DmText.sectionHead), + ); + } +} + +/// Screen heading and its supporting line — shared by every booking step. +class DmScreenHeader extends StatelessWidget { + const DmScreenHeader({super.key, required this.title, this.lede, this.leding}); + + final String title; + final String? lede; + + /// Rich alternative to [lede]. + final Widget? leding; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text(title, style: DmText.title), + if (lede != null) ...[ + const SizedBox(height: 8), + Text(lede!, style: DmText.lede), + ], + if (leding != null) ...[const SizedBox(height: 10), leding!], + const SizedBox(height: 22), + ], + ); + } +} diff --git a/lib/ui/widgets/feedback.dart b/lib/ui/widgets/feedback.dart new file mode 100644 index 0000000..e1ef0dc --- /dev/null +++ b/lib/ui/widgets/feedback.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; + +import '../tokens.dart'; + +/// Dark pill toast, matching the prototype's transient confirmations. +class DmToast { + DmToast._(); + + static OverlayEntry? _entry; + + static void show(BuildContext context, String message) { + final overlay = Overlay.maybeOf(context); + if (overlay == null) return; + + _entry?.remove(); + final entry = OverlayEntry( + builder: (context) => _ToastBody(message: message), + ); + _entry = entry; + overlay.insert(entry); + + Future.delayed(const Duration(milliseconds: 2400), () { + if (_entry == entry) { + entry.remove(); + _entry = null; + } + }); + } + + /// Called when the app is torn down in tests. + static void dismiss() { + _entry?.remove(); + _entry = null; + } +} + +class _ToastBody extends StatefulWidget { + const _ToastBody({required this.message}); + final String message; + + @override + State<_ToastBody> createState() => _ToastBodyState(); +} + +class _ToastBodyState extends State<_ToastBody> + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 240), + )..forward(); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bottom = MediaQuery.of(context).viewInsets.bottom; + return Positioned( + left: 20, + right: 20, + bottom: 96 + bottom, + child: FadeTransition( + opacity: _c, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.3), + end: Offset.zero, + ).animate(CurvedAnimation(parent: _c, curve: Curves.easeOutCubic)), + child: Center( + child: Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 11, + ), + decoration: BoxDecoration( + color: const Color(0xF0191716), + borderRadius: DmRadius.all(DmRadius.sm), + ), + child: Text( + widget.message, + textAlign: TextAlign.center, + style: DmText.label.copyWith( + fontSize: 13.5, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +/// Bottom sheet with the prototype's grip, radius and padding. +Future showDmSheet({ + required BuildContext context, + required WidgetBuilder builder, + bool isScrollControlled = true, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: isScrollControlled, + backgroundColor: DmColors.canvas, + barrierColor: DmColors.scrim, + elevation: 0, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(DmRadius.xl)), + ), + clipBehavior: Clip.antiAlias, + builder: (context) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + top: false, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.86, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB( + DmSpace.pad, + 10, + DmSpace.pad, + 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _SheetGrip(), + Flexible(child: builder(context)), + ], + ), + ), + ), + ), + ), + ); +} + +class _SheetGrip extends StatelessWidget { + const _SheetGrip(); + + @override + Widget build(BuildContext context) { + return Container( + width: 36, + height: 4, + margin: const EdgeInsets.only(top: 2, bottom: 18), + decoration: BoxDecoration( + color: DmColors.border, + borderRadius: DmRadius.all(2), + ), + ); + } +} + +/// Sheet heading + supporting line. +class DmSheetHeader extends StatelessWidget { + const DmSheetHeader({super.key, required this.title, this.subtitle}); + + final String title; + final String? subtitle; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: DmText.sheetTitle), + if (subtitle != null) ...[ + const SizedBox(height: 3), + Text(subtitle!, style: DmText.tiny), + ], + const SizedBox(height: 18), + ], + ); + } +} diff --git a/lib/ui/widgets/inputs.dart b/lib/ui/widgets/inputs.dart new file mode 100644 index 0000000..35adffd --- /dev/null +++ b/lib/ui/widgets/inputs.dart @@ -0,0 +1,323 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../tokens.dart'; +import 'cards.dart'; + +/// Labelled field. +/// +/// White with a hairline at rest, and on focus the edge goes crimson with a +/// 3pt wash ring around it. The ring is the whole reason this is a container +/// rather than an `InputDecoration`: it is a spread shadow in the brand's +/// lightest tint, which no `InputBorder` can draw, and it is what makes the +/// focused field read as lit rather than merely outlined. +/// +/// [optional] appends the grey marker that keeps skippable destination and +/// recipient fields honestly signposted. +class DmTextField extends StatefulWidget { + const DmTextField({ + super.key, + required this.label, + required this.controller, + this.hint, + this.optional = false, + this.keyboardType, + this.maxLength, + this.maxLines = 1, + this.prefix, + this.textInputAction, + this.onSubmitted, + this.autofocus = false, + this.digitsOnly = false, + this.mono = false, + this.onClear, + }); + + final String label; + final TextEditingController controller; + final String? hint; + final bool optional; + final TextInputType? keyboardType; + final int? maxLength; + final int maxLines; + final String? prefix; + final TextInputAction? textInputAction; + final ValueChanged? onSubmitted; + final bool autofocus; + final bool digitsOnly; + + /// Sets the value in mono — a pincode or a phone number, where the customer + /// checks the digits back. + final bool mono; + + /// Shows a clear button on the right when non-null. + final VoidCallback? onClear; + + @override + State createState() => _DmTextFieldState(); +} + +class _DmTextFieldState extends State { + final FocusNode _node = FocusNode(); + + @override + void initState() { + super.initState(); + _node.addListener(() => setState(() {})); + } + + @override + void dispose() { + _node.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final focused = _node.hasFocus; + final textStyle = widget.mono + ? DmText.monoLg.copyWith(fontSize: 15.5, color: DmColors.ink) + : DmText.field; + + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + style: DmText.label, + children: [ + TextSpan(text: widget.label), + if (widget.optional) + const TextSpan( + text: ' Optional', + style: TextStyle( + color: DmColors.ink4, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + AnimatedContainer( + duration: DmMotion.fast, + curve: DmMotion.ease, + constraints: BoxConstraints( + minHeight: widget.maxLines > 1 ? 0 : DmSpace.field, + ), + decoration: BoxDecoration( + color: DmColors.surface, + borderRadius: DmRadius.all(DmRadius.sm), + border: Border.all( + color: focused ? DmColors.brand : DmColors.border, + ), + boxShadow: focused + ? const [ + BoxShadow( + color: DmColors.brandSoft, + spreadRadius: 3, + blurRadius: 0, + ), + ] + : null, + ), + child: Row( + crossAxisAlignment: widget.maxLines > 1 + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + if (widget.prefix != null) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(15, 0, 12, 0), + child: Text( + widget.prefix!, + style: DmText.field.copyWith( + color: DmColors.ink2, + fontWeight: FontWeight.w700, + ), + ), + ), + // A hairline between the country code and the number: they + // are two different things and reading them as one string is + // how a digit gets lost. + Container(width: 1, height: 22, color: DmColors.border), + ], + Expanded( + child: TextField( + controller: widget.controller, + focusNode: _node, + keyboardType: widget.keyboardType, + maxLines: widget.maxLines, + autofocus: widget.autofocus, + textInputAction: widget.textInputAction, + onSubmitted: widget.onSubmitted, + cursorColor: DmColors.brand, + cursorRadius: const Radius.circular(2), + style: textStyle, + inputFormatters: [ + if (widget.maxLength != null) + LengthLimitingTextInputFormatter(widget.maxLength), + if (widget.digitsOnly) + FilteringTextInputFormatter.digitsOnly, + ], + decoration: InputDecoration( + isDense: true, + hintText: widget.hint, + hintStyle: DmText.field.copyWith( + color: DmColors.ink3, + fontWeight: FontWeight.w500, + ), + filled: false, + counterText: '', + contentPadding: EdgeInsets.fromLTRB( + widget.prefix == null ? 15 : 12, + widget.maxLines > 1 ? 15 : 14, + 8, + widget.maxLines > 1 ? 15 : 14, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + ), + ), + if (widget.onClear != null) + Padding( + padding: const EdgeInsets.only(right: 6), + child: InkWell( + onTap: widget.onClear, + borderRadius: DmRadius.all(DmRadius.xs), + child: const SizedBox( + width: 34, + height: 34, + child: Icon(LucideIcons.x, size: 17, color: DmColors.ink3), + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// A settings row: label left, value or control right. +class DmRow extends StatelessWidget { + const DmRow({ + super.key, + required this.label, + this.value, + this.trailing, + this.onTap, + this.showChevron = false, + this.icon, + }); + + final String label; + final String? value; + final Widget? trailing; + final VoidCallback? onTap; + final bool showChevron; + + /// The glyph in the row's leading tile. Account's rows all carry one, so the + /// list can be scanned by shape before it is read. + final IconData? icon; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + splashColor: DmColors.brandSoft, + highlightColor: DmColors.brandSoft.withValues(alpha: 0.5), + child: Container( + constraints: const BoxConstraints(minHeight: 56), + padding: const EdgeInsets.fromLTRB(16, 11, 16, 11), + child: Row( + children: [ + if (icon != null) ...[ + DmTile(icon: icon, tone: DmTileTone.wash), + const SizedBox(width: 12), + ], + // The label is what the row IS, so it never wraps and never + // gives up width; the value is context and ellipsizes. A saved + // address as a value would otherwise push "Saved addresses" onto + // two lines. + Expanded( + flex: 3, + child: Text( + label, + style: DmText.cardTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (value != null) ...[ + const SizedBox(width: 10), + Flexible( + flex: 2, + child: Text( + value!, + style: DmText.value, + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ?trailing, + if (showChevron) ...[ + const SizedBox(width: 8), + const Icon( + LucideIcons.chevronRight, + size: 18, + color: DmColors.ink4, + ), + ], + ], + ), + ), + ), + ); + } +} + +/// Card that rules a group of [DmRow]s together. +class DmRowGroup extends StatelessWidget { + const DmRowGroup({super.key, required this.children}); + + final List children; + + @override + Widget build(BuildContext context) => DmCard(children: children); +} + +/// Switch in brand colours. +class DmSwitch extends StatelessWidget { + const DmSwitch({super.key, required this.value, required this.onChanged}); + + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Switch.adaptive( + value: value, + onChanged: (v) { + HapticFeedback.selectionClick(); + onChanged(v); + }, + activeThumbColor: Colors.white, + activeTrackColor: DmColors.brand, + inactiveThumbColor: Colors.white, + inactiveTrackColor: DmColors.border, + trackOutlineColor: WidgetStateProperty.all(Colors.transparent), + ); + } +} diff --git a/lib/ui/widgets/map_panel.dart b/lib/ui/widgets/map_panel.dart new file mode 100644 index 0000000..f22a739 --- /dev/null +++ b/lib/ui/widgets/map_panel.dart @@ -0,0 +1,454 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import '../tokens.dart'; +import 'map_tiles.dart'; + +/// The pickup map: a real, pannable map with a pin fixed to the centre. +/// +/// The pin never moves — the map moves under it, the way every ride-hailing +/// app does it. When the map settles, [onPinMoved] fires with the coordinates +/// under the pin so the caller can reverse-geocode them. +class DmMapPanel extends StatefulWidget { + const DmMapPanel({ + super.key, + required this.lat, + required this.lng, + this.height, + this.loading = false, + this.interactive = true, + this.zoom = 16.5, + this.deviceLat, + this.deviceLng, + this.deviceAccuracy, + this.onLocate, + this.onPinMoveStart, + this.onPinMoved, + this.borderRadius = const BorderRadius.all(Radius.circular(DmRadius.lg)), + }); + + /// Where the pin sits. Changing these recentres the map, unless the customer + /// is mid-gesture — their drag always wins. + final double lat; + final double lng; + + /// Fixed height, or null to fill whatever the parent gives it — which is how + /// it sits inside the pickup card. + final double? height; + + /// True while we are still resolving a position; dims the pin. + final bool loading; + + /// False makes the map a picture: no dragging, no pin lift, no callbacks. + final bool interactive; + + final double zoom; + + /// Where the customer is, drawn as the blue dot. Null hides it — a refused + /// permission should show no dot rather than a dot in the wrong place. + /// + /// Distinct from [lat]/[lng], which is where the *pickup pin* is: after a + /// drag the two separate, which is exactly the information the dot carries. + final double? deviceLat; + final double? deviceLng; + + /// Horizontal accuracy in metres. Drawn as a halo at true map scale, so it + /// shrinks as the customer zooms in. Null draws the dot alone. + final double? deviceAccuracy; + + /// Shows the "my location" control when set. + final VoidCallback? onLocate; + + /// Fires the moment a drag begins, so the address can go stale-quiet. + final VoidCallback? onPinMoveStart; + + /// Fires once the map has come to rest under the pin. + final void Function(double lat, double lng)? onPinMoved; + + final BorderRadius borderRadius; + + @override + State createState() => _DmMapPanelState(); +} + +class _DmMapPanelState extends State with TickerProviderStateMixin { + final MapController _map = MapController(); + + /// Raises the pin off the map while the customer drags. + late final AnimationController _lift = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 180), + ); + + late final AnimationController _glide = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 420), + ); + + Timer? _settle; + bool _ready = false; + bool _dragging = false; + + /// The last position the parent knows about — either what it handed us or + /// what we last reported. Used to tell an external recentre from an echo of + /// our own callback. + late LatLng _known = LatLng(widget.lat, widget.lng); + + @override + void dispose() { + _settle?.cancel(); + _lift.dispose(); + _glide.dispose(); + _map.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant DmMapPanel old) { + super.didUpdateWidget(old); + if (widget.lat == old.lat && widget.lng == old.lng) return; + + final target = LatLng(widget.lat, widget.lng); + // Our own reverse-geocode coming back, or a genuinely new place? + if (_metres(target, _known) < 20) { + _known = target; + return; + } + _known = target; + if (!_dragging) _glideTo(target); + } + + /// flutter_map moves instantly; a short eased glide keeps "locate me" and a + /// searched address from teleporting. + void _glideTo(LatLng target) { + if (!_ready) return; + final from = _map.camera.center; + final fromZoom = _map.camera.zoom; + _glide + ..stop() + ..reset(); + + void tick() { + final t = Curves.easeInOutCubic.transform(_glide.value); + _map.move( + LatLng( + from.latitude + (target.latitude - from.latitude) * t, + from.longitude + (target.longitude - from.longitude) * t, + ), + fromZoom + (widget.zoom - fromZoom) * t, + ); + } + + _glide.addListener(tick); + _glide.forward().whenComplete(() => _glide.removeListener(tick)); + } + + void _onEvent(MapEvent event) { + if (!widget.interactive) return; + + if (event is MapEventMoveStart || + event is MapEventFlingAnimationStart || + event is MapEventDoubleTapZoomStart) { + _settle?.cancel(); + if (!_dragging) { + _dragging = true; + _lift.forward(); + widget.onPinMoveStart?.call(); + } + return; + } + + if (event is MapEventMoveEnd || + event is MapEventFlingAnimationEnd || + event is MapEventDoubleTapZoomEnd) { + _dragging = false; + _lift.reverse(); + final centre = event.camera.center; + // A flick that ends and restarts should resolve once, not twice. + _settle?.cancel(); + _settle = Timer(const Duration(milliseconds: 320), () { + if (!mounted) return; + _known = centre; + widget.onPinMoved?.call(centre.latitude, centre.longitude); + }); + } + } + + static double _metres(LatLng a, LatLng b) { + const earth = 6371000.0; + double rad(double d) => d * math.pi / 180; + final dLat = rad(b.latitude - a.latitude); + final dLng = rad(b.longitude - a.longitude); + final h = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(rad(a.latitude)) * + math.cos(rad(b.latitude)) * + math.sin(dLng / 2) * + math.sin(dLng / 2); + return earth * 2 * math.atan2(math.sqrt(h), math.sqrt(1 - h)); + } + + /// The customer's own position: an accuracy halo at map scale with the dot + /// on top. Returns null when there is no fix to draw. + Widget? get _deviceLayers { + final lat = widget.deviceLat; + final lng = widget.deviceLng; + if (lat == null || lng == null) return null; + final at = LatLng(lat, lng); + final accuracy = widget.deviceAccuracy; + + return Stack( + children: [ + if (accuracy != null && accuracy > 0) + CircleLayer( + circles: [ + CircleMarker( + point: at, + // True metres, so the halo tells the truth at every zoom. + radius: accuracy, + useRadiusInMeter: true, + color: DmColors.locationHalo, + borderColor: DmColors.locationDot.withValues(alpha: 0.28), + borderStrokeWidth: 1, + ), + ], + ), + MarkerLayer( + markers: [ + Marker( + key: const ValueKey('deviceLocationDot'), + point: at, + width: 22, + height: 22, + child: const _DeviceDot(), + ), + ], + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + final map = ClipRRect( + borderRadius: widget.borderRadius, + child: ColoredBox( + color: DmMapTiles.ground, + child: Stack( + children: [ + Positioned.fill( + child: FlutterMap( + mapController: _map, + options: MapOptions( + initialCenter: LatLng(widget.lat, widget.lng), + initialZoom: widget.zoom, + minZoom: 4, + maxZoom: 19, + backgroundColor: DmMapTiles.ground, + onMapReady: () => _ready = true, + onMapEvent: _onEvent, + interactionOptions: InteractionOptions( + // North stays up: rotation buys nothing here and makes + // the fixed pin read as broken. + flags: widget.interactive + ? InteractiveFlag.drag | + InteractiveFlag.flingAnimation | + InteractiveFlag.pinchMove | + InteractiveFlag.pinchZoom | + InteractiveFlag.doubleTapZoom + : InteractiveFlag.none, + ), + ), + children: [ + DmMapTiles.layer(context), + ?_deviceLayers, + ], + ), + ), + + // The pin is an overlay, not a marker: it belongs to the screen, + // not to a coordinate. + Center( + child: IgnorePointer( + child: AnimatedOpacity( + opacity: widget.loading ? 0.45 : 1, + duration: DmMotion.base, + child: AnimatedBuilder( + animation: _lift, + builder: (context, child) => _CentrePin(lift: _lift.value), + ), + ), + ), + ), + + if (widget.onLocate != null) + Positioned( + right: 12, + bottom: 12, + child: _LocateButton( + busy: widget.loading, + onTap: widget.onLocate!, + ), + ), + + const Positioned(left: 10, bottom: 10, child: DmMapAttribution()), + ], + ), + ), + ); + + return widget.height == null + ? map + : SizedBox(height: widget.height, child: map); + } +} + +/// The fixed centre pin. [lift] 0..1 raises it and shrinks its shadow, so a +/// drag feels like picking the pin up rather than dragging the ground. +class _CentrePin extends StatelessWidget { + const _CentrePin({required this.lift}); + + final double lift; + + @override + Widget build(BuildContext context) { + final eased = Curves.easeOutCubic.transform(lift); + return SizedBox( + width: 60, + height: 74, + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + // Ground shadow — the anchor point the pin actually refers to. + Positioned( + bottom: 8, + child: Transform.scale( + scale: 1 - eased * 0.35, + child: Container( + width: 18, + height: 6, + decoration: BoxDecoration( + color: DmColors.ink.withValues(alpha: 0.22 - eased * 0.1), + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ), + Positioned( + bottom: 10 + eased * 12, + child: const _PinHead(), + ), + ], + ), + ); + } +} + +/// "You are here". White ring so it stays legible over any cartography, and a +/// soft shadow so it sits on the map rather than in it. +class _DeviceDot extends StatelessWidget { + const _DeviceDot(); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: DmColors.locationDot, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: const [ + BoxShadow( + color: Color(0x3D14100F), + blurRadius: 6, + offset: Offset(0, 2), + ), + ], + ), + ), + ); + } +} + +class _PinHead extends StatelessWidget { + const _PinHead(); + + @override + Widget build(BuildContext context) { + return const Stack( + alignment: Alignment.topCenter, + children: [ + Icon( + LucideIcons.mapPin, + size: 46, + color: DmColors.brand, + shadows: [ + Shadow( + color: Color(0x408F0F06), + blurRadius: 12, + offset: Offset(0, 5), + ), + ], + ), + Positioned( + top: 12, + child: CircleAvatar(radius: 5.5, backgroundColor: Colors.white), + ), + ], + ); + } +} + +class _LocateButton extends StatelessWidget { + const _LocateButton({required this.onTap, this.busy = false}); + + final VoidCallback onTap; + final bool busy; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: DmRadius.all(DmRadius.md), + boxShadow: DmShadow.float, + ), + child: Material( + color: DmColors.surface, + borderRadius: DmRadius.all(DmRadius.md), + child: InkWell( + onTap: busy ? null : onTap, + borderRadius: DmRadius.all(DmRadius.md), + child: Container( + width: DmSpace.tap, + height: DmSpace.tap, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: DmRadius.all(DmRadius.md), + border: Border.all(color: DmColors.line), + ), + child: busy + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: DmColors.brand, + ), + ) + : const Icon( + LucideIcons.locateFixed, + size: 22, + color: DmColors.ink, + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/widgets/map_tiles.dart b/lib/ui/widgets/map_tiles.dart new file mode 100644 index 0000000..a2cf33a --- /dev/null +++ b/lib/ui/widgets/map_tiles.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; + +import '../../data/map_config.dart'; +import '../tokens.dart'; + +/// Turns the configured tile provider ([DmMapConfig]) into a flutter_map layer. +/// +/// The one bridge between map configuration and map rendering: no widget in +/// this app knows a tile URL, a subdomain list or an API key, and changing +/// provider never reaches a screen. +class DmMapTiles { + DmMapTiles._(); + + /// What shows through before a tile paints, or when one never arrives. + static const ground = Color(0xFFF0EDE9); + + static DmMapConfig get config => DmMapConfig.instance; + + static TileLayer layer( + BuildContext context, { + DmMapStyle style = DmMapStyle.streets, + }) { + final map = config; + return TileLayer( + urlTemplate: map.urlFor(style), + subdomains: map.subdomains, + // Identifies the app to the provider so our traffic is attributable and + // can be rate-limited or allow-listed properly. + userAgentPackageName: DmMapConfig.packageName, + tileProvider: NetworkTileProvider( + headers: {'User-Agent': map.userAgent}, + // A failed tile is a cosmetic event — the ground colour covers it. + silenceExceptions: true, + ), + retinaMode: map.retina && RetinaMode.isHighDensity(context), + maxNativeZoom: map.maxNativeZoom, + // Defaults, stated so they are not "tuned" upward later: the app fetches + // only the tiles it is about to draw plus the standard render margin. + // Bulk prefetching or scraping a provider's tiles is out of bounds. + keepBuffer: 2, + panBuffer: 1, + tileDisplay: const TileDisplay.fadeIn( + duration: Duration(milliseconds: 160), + ), + // Offline, or under `flutter test`, tiles simply never arrive. + errorTileCallback: (_, _, _) {}, + ); + } +} + +/// The credit line the tile licence requires. Small, quiet, and on every map — +/// its text comes from the provider, so it stays correct after a swap. +class DmMapAttribution extends StatelessWidget { + const DmMapAttribution({super.key, this.dark = false}); + + /// Slightly stronger contrast for maps with busy cartography behind it. + final bool dark; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: DmColors.surface.withValues(alpha: dark ? 0.92 : 0.82), + borderRadius: DmRadius.all(DmRadius.pill), + ), + child: Text( + DmMapTiles.config.attribution, + style: DmText.small.copyWith(fontSize: 9.5, color: DmColors.ink3), + ), + ), + ); + } +} diff --git a/lib/ui/widgets/milestones.dart b/lib/ui/widgets/milestones.dart new file mode 100644 index 0000000..fffb205 --- /dev/null +++ b/lib/ui/widgets/milestones.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; + +import '../../data/models.dart'; +import '../format.dart'; +import '../tokens.dart'; +import 'buttons.dart'; +import 'cards.dart'; + +/// The customer's journey: seven milestones, not nine backend states. +/// +/// Operational detail — "Miler on the way", "Order created" — is shown as +/// context beneath the milestone that is currently happening, so a transition +/// informs without becoming another permanent row. +/// +/// ── Newest first, and only as many as matter ── +/// +/// The list used to run oldest-to-newest, full height, every time: seven rows +/// of which four were grey placeholders for things that have not happened. On +/// a screen whose job is "where is my parcel right now", the answer was at the +/// bottom, below the scroll. It now leads with what just happened and, when +/// [maxRows] is set, keeps the rest behind one line — the same compression the +/// design applies to the live tracking card. +class DmMilestones extends StatelessWidget { + const DmMilestones({ + super.key, + required this.booking, + this.stage, + this.title = 'Recent updates', + this.maxRows, + this.onExpand, + }); + + final Booking booking; + + /// Journey to show. Defaults to the pickup's own stage; a destination order + /// passes its own. + final JourneyStage? stage; + + final String title; + + /// Show at most this many rows, newest first. Null shows the whole journey. + final int? maxRows; + + /// Tapped from the "All n stages" action. Null hides the action. + final VoidCallback? onExpand; + + @override + Widget build(BuildContext context) { + final current = stage ?? booking.stage; + final currentMilestone = CustomerMilestone.of(current); + final milestones = CustomerMilestone.values; + final currentIndex = currentMilestone.index; + + // Newest first: the current milestone, then everything already done, then + // — only if there is room — what is still to come. + final ordered = [ + for (var i = currentIndex; i >= 0; i--) i, + for (var i = currentIndex + 1; i < milestones.length; i++) i, + ]; + final shown = maxRows == null ? ordered : ordered.take(maxRows!).toList(); + + return DmCard( + children: [ + DmCardCell( + padding: const EdgeInsets.fromLTRB(16, 15, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title.toUpperCase(), + style: DmText.eyebrow.copyWith(color: DmColors.brand), + ), + ), + if (onExpand != null && shown.length < ordered.length) + DmTextAction( + label: 'All ${milestones.length} stages', + onPressed: onExpand, + minHeight: 24, + ) + else + Text( + 'Step ${currentIndex + 1} of ${milestones.length}', + style: DmText.monoSm, + ), + ], + ), + const SizedBox(height: 14), + for (var n = 0; n < shown.length; n++) + _MilestoneRow( + milestone: milestones[shown[n]], + done: shown[n] < currentIndex, + isCurrent: shown[n] == currentIndex, + last: n == shown.length - 1, + time: shown[n] <= currentIndex + ? relativeTime(booking.timeOf(milestones[shown[n]].firstStage)) + : '', + detail: shown[n] == currentIndex ? _detailFor(current) : null, + ), + ], + ), + ), + ], + ); + } + + /// One line of operational context for the stage actually in progress. + String? _detailFor(JourneyStage current) => switch (current) { + JourneyStage.booked => 'Matching the nearest available Miler', + JourneyStage.assigned => + booking.miler == null + ? null + : '${booking.miler!.name} accepted this pickup', + JourneyStage.onTheWay => + booking.milerDistanceKm == null + ? 'Miler on the way' + : 'Miler on the way · ${booking.milerDistanceKm} km away, ' + 'about ${booking.milerEtaMinutes} min', + JourneyStage.arrived => 'Miler arrived at your door · verification OTP ready', + JourneyStage.pickedUp => 'Weighing and sealing your packages', + JourneyStage.orderCreated => + booking.isMultiDestination + ? 'Order created for each destination · tracking issued' + : 'Order created · tracking number issued', + JourneyStage.inTransit => 'Moving through our network', + JourneyStage.outForDelivery => 'Out with the delivery Miler', + JourneyStage.delivered => null, + }; +} + +class _MilestoneRow extends StatelessWidget { + const _MilestoneRow({ + required this.milestone, + required this.done, + required this.isCurrent, + required this.last, + required this.time, + this.detail, + }); + + final CustomerMilestone milestone; + final bool done; + final bool isCurrent; + final bool last; + final String time; + final String? detail; + + @override + Widget build(BuildContext context) { + final upcoming = !done && !isCurrent; + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 12, + child: Column( + children: [ + const SizedBox(height: 3), + _Marker(done: done, isCurrent: isCurrent), + if (!last) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Container(width: 1.5, color: DmColors.border), + ), + ), + ], + ), + ), + const SizedBox(width: 14), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: last ? 0 : 15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + milestone.label, + style: upcoming + ? DmText.small.copyWith(color: DmColors.ink3) + : DmText.cardTitle.copyWith(fontSize: 14.5), + ), + ), + if (time.isNotEmpty) ...[ + const SizedBox(width: 10), + Text(time, style: DmText.monoSm), + ], + ], + ), + if (detail != null) ...[ + const SizedBox(height: 4), + Text(detail!, style: DmText.small), + ], + ], + ), + ), + ), + ], + ), + ); + } +} + +class _Marker extends StatelessWidget { + const _Marker({required this.done, required this.isCurrent}); + + final bool done; + final bool isCurrent; + + @override + Widget build(BuildContext context) { + if (isCurrent) { + return Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + color: DmColors.brand, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow(color: DmColors.brandSoft, spreadRadius: 4), + ], + ), + ); + } + return Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: done ? DmColors.brand : DmColors.border, + shape: BoxShape.circle, + ), + ); + } +} diff --git a/lib/ui/widgets/misc.dart b/lib/ui/widgets/misc.dart new file mode 100644 index 0000000..477f407 --- /dev/null +++ b/lib/ui/widgets/misc.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; + +import '../tokens.dart'; + +/// One accent, three intensities — the design's `.st`. +enum DmPillTone { + /// Something is happening right now and nobody has to do anything: searching + /// for a Miler, out for delivery. Crimson field, white label, solid dot. + live, + + /// Moving, but between beats — assigned, in transit. The crimson is held in + /// an outline so a list of six orders is not six red blocks. + neutral, + + /// Finished. Grey, with a filled dot: the journey happened. + done, + + /// Never happened. Grey, hollow dot. + off, +} + +/// Status pill used on order rows and at the head of a tracking card. +class DmPill extends StatelessWidget { + const DmPill(this.label, {super.key, this.tone = DmPillTone.neutral}); + + final String label; + final DmPillTone tone; + + @override + Widget build(BuildContext context) { + final (Color bg, Color fg, BoxBorder? edge) = switch (tone) { + DmPillTone.live => (DmColors.brand, Colors.white, null), + DmPillTone.neutral => ( + DmColors.surface, + DmColors.brand, + Border.all(color: DmColors.brandLine), + ), + DmPillTone.done => (DmColors.surfaceAlt, DmColors.ink3, null), + DmPillTone.off => (DmColors.surfaceAlt, DmColors.ink3, null), + }; + + // The dot is the tone's tell at a glance, before the word is read: filled + // for anything that happened or is happening, hollow for what never did. + final hollow = tone == DmPillTone.off; + + return Container( + padding: const EdgeInsets.fromLTRB(11, 5, 11, 5), + decoration: BoxDecoration( + color: bg, + border: edge, + borderRadius: DmRadius.all(5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(right: 7), + decoration: BoxDecoration( + color: hollow ? Colors.transparent : fg, + shape: BoxShape.circle, + border: hollow ? Border.all(color: fg, width: 1.5) : null, + ), + ), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.label.copyWith(fontSize: 12.5, color: fg), + ), + ), + ], + ), + ); + } +} + +/// The pulsing dot that marks a live booking. +class DmLiveDot extends StatefulWidget { + const DmLiveDot({super.key, this.size = 9, this.color = DmColors.brand}); + + final double size; + final Color color; + + @override + State createState() => _DmLiveDotState(); +} + +class _DmLiveDotState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1900), + ); + + @override + void initState() { + super.initState(); + // A ring that never stops is a battery cost and, for anyone who asked the + // system not to animate, a distraction. Honour the setting: the dot still + // reads as live because it is a filled brand dot on a quiet row. + if (!WidgetsBinding.instance.platformDispatcher.accessibilityFeatures.disableAnimations) { + _c.repeat(); + } + } + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final span = widget.size + 10; + return SizedBox( + width: span, + height: span, + child: AnimatedBuilder( + animation: _c, + builder: (context, child) { + return Stack( + alignment: Alignment.center, + children: [ + if (_c.isAnimating) + Transform.scale( + scale: 1 + _c.value * 1.6, + child: Opacity( + opacity: (1 - _c.value) * 0.5, + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + ), + ), + ), + ), + child!, + ], + ); + }, + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + ), + ), + ), + ); + } +} + +/// Initials avatar. Crimson-filled on a light surface, lightened white when it +/// sits on the brand header. +class DmAvatar extends StatelessWidget { + const DmAvatar({ + super.key, + required this.initials, + this.size = 42, + this.onBrand = false, + }); + + final String initials; + final double size; + final bool onBrand; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: onBrand ? DmColors.onBrandRaise : DmColors.brand, + shape: BoxShape.circle, + ), + child: Text( + initials, + style: DmText.label.copyWith( + fontSize: size < 40 ? 12 : 13, + fontWeight: FontWeight.w800, + fontVariations: const [FontVariation('wght', 800)], + letterSpacing: 0.13, + color: Colors.white, + ), + ), + ); + } +} + +/// The Doormile logomark: a door inside a house silhouette. +class DoormileMark extends StatelessWidget { + const DoormileMark({super.key, this.size = 52, this.inverted = false}); + + final double size; + + /// White strokes on nothing, for use on the crimson field. + final bool inverted; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: inverted ? Colors.transparent : DmColors.brand, + borderRadius: DmRadius.all(size * 0.24), + ), + child: CustomPaint( + painter: const _MarkPainter(), + size: Size.square(size), + child: SizedBox.square(dimension: size), + ), + ); + } +} + +class _MarkPainter extends CustomPainter { + const _MarkPainter(); + + @override + void paint(Canvas canvas, Size size) { + final s = size.width; + final paint = Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = s * 0.072 + ..strokeJoin = StrokeJoin.round + ..strokeCap = StrokeCap.round; + + // Roof + walls, drawn on a 24x24 grid scaled to the mark. + double u(double v) => v / 24 * s * 0.62 + s * 0.19; + + final house = Path() + ..moveTo(u(3.6), u(9.2)) + ..lineTo(u(12), u(4)) + ..lineTo(u(20.4), u(9.2)) + ..lineTo(u(20.4), u(20)) + ..lineTo(u(3.6), u(20)) + ..close(); + + final door = Path() + ..moveTo(u(9.4), u(20)) + ..lineTo(u(9.4), u(13)) + ..lineTo(u(14.6), u(13)) + ..lineTo(u(14.6), u(20)); + + canvas.drawPath(house, paint); + canvas.drawPath(door, paint); + } + + @override + bool shouldRepaint(covariant _MarkPainter oldDelegate) => false; +} diff --git a/lib/ui/widgets/option_tile.dart b/lib/ui/widgets/option_tile.dart new file mode 100644 index 0000000..72119e2 --- /dev/null +++ b/lib/ui/widgets/option_tile.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../tokens.dart'; + +/// The selectable row used for serviceable states, districts and pickup slots. +/// +/// Selection **fills**: the chosen slot becomes a crimson card with a glow +/// under it, and every other option stays a white card with a hairline. The +/// earlier design tinted the selected tile instead, which on a list of five +/// slots left the customer hunting for which one they had picked. A filled row +/// is unmistakable from across the room, and the radio on the right keeps the +/// control legible for anyone who does not read the colour. +/// +/// Unavailable options stay visible but recede, with the backend's reason +/// attached — coverage should be legible, not hidden. +class DmOptionTile extends StatefulWidget { + const DmOptionTile({ + super.key, + required this.title, + this.subtitle, + this.selected = false, + this.enabled = true, + this.onTap, + this.leading, + this.tags, + this.trailing, + this.titleStyle, + }); + + final String title; + final String? subtitle; + final bool selected; + final bool enabled; + final VoidCallback? onTap; + + /// Optional leading glyph. + final IconData? leading; + + /// Chips above the title — "Fastest", "4 Milers nearby". + final List? tags; + + /// Replaces the radio — "Notify me" on a full slot. + final Widget? trailing; + + /// Slots set their time in the display face; states and districts do not. + final TextStyle? titleStyle; + + @override + State createState() => _DmOptionTileState(); +} + +class _DmOptionTileState extends State { + bool _down = false; + + @override + Widget build(BuildContext context) { + final selected = widget.selected; + final enabled = widget.enabled; + + final Color background = selected + ? DmColors.brand + : (enabled ? DmColors.surface : DmColors.surfaceAlt); + + final Color edge = selected + ? DmColors.brand + : (enabled ? DmColors.border : DmColors.border); + + final titleColor = selected + ? Colors.white + : (enabled ? DmColors.ink : DmColors.ink3); + + final subColor = selected + ? DmColors.onBrand2 + : (enabled ? DmColors.ink2 : DmColors.ink3); + + return Semantics( + button: true, + selected: selected, + enabled: enabled, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: enabled ? (_) => setState(() => _down = true) : null, + onTapUp: enabled ? (_) => setState(() => _down = false) : null, + onTapCancel: enabled ? () => setState(() => _down = false) : null, + onTap: enabled + ? () { + HapticFeedback.selectionClick(); + widget.onTap?.call(); + } + : null, + child: AnimatedSlide( + offset: _down ? const Offset(0, 0.012) : Offset.zero, + duration: DmMotion.fast, + curve: DmMotion.ease, + child: AnimatedContainer( + duration: DmMotion.base, + curve: DmMotion.ease, + constraints: const BoxConstraints(minHeight: 62), + padding: EdgeInsets.fromLTRB( + widget.leading == null ? 16 : 14, + 14, + 16, + 14, + ), + decoration: BoxDecoration( + color: background, + borderRadius: DmRadius.all(DmRadius.md), + border: Border.all(color: edge), + boxShadow: selected ? DmShadow.lift : null, + ), + child: Row( + children: [ + if (widget.leading != null) ...[ + Icon( + widget.leading, + size: 20, + color: selected ? Colors.white : DmColors.ink3, + ), + const SizedBox(width: 13), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.tags != null && widget.tags!.isNotEmpty) ...[ + Wrap( + spacing: 7, + runSpacing: 6, + children: widget.tags!, + ), + const SizedBox(height: 9), + ], + Text( + widget.title, + style: (widget.titleStyle ?? DmText.cardTitle) + .copyWith(color: titleColor), + ), + if (widget.subtitle != null) ...[ + const SizedBox(height: 3), + Text( + widget.subtitle!, + style: DmText.small.copyWith(color: subColor), + ), + ], + ], + ), + ), + const SizedBox(width: 12), + widget.trailing ?? + _Radio(selected: selected, enabled: enabled), + ], + ), + ), + ), + ), + ); + } +} + +/// A radio, not a check: these lists are single-choice, and a tick reads as +/// "done" where a radio reads as "this one, of these". +class _Radio extends StatelessWidget { + const _Radio({required this.selected, required this.enabled}); + + final bool selected; + final bool enabled; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: DmMotion.base, + curve: DmMotion.ease, + width: 22, + height: 22, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: selected + ? Colors.white + : (enabled ? DmColors.ink4 : DmColors.border), + width: selected ? 2 : 1.5, + ), + ), + child: selected + ? Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ) + : null, + ); + } +} + +/// A tappable row with a leading tile — "Drop a map pin", "Saved addresses". +class DmActionRow extends StatelessWidget { + const DmActionRow({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + this.onTap, + this.subtitleColor, + this.active = false, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback? onTap; + final Color? subtitleColor; + + /// Tints the tile once the action has been completed. + final bool active; + + @override + Widget build(BuildContext context) { + return Material( + color: DmColors.surface, + borderRadius: DmRadius.all(DmRadius.md), + child: InkWell( + onTap: onTap, + borderRadius: DmRadius.all(DmRadius.md), + child: Ink( + decoration: BoxDecoration( + color: DmColors.surface, + border: Border.all(color: DmColors.border), + borderRadius: DmRadius.all(DmRadius.md), + ), + child: Container( + constraints: const BoxConstraints(minHeight: 64), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + Container( + width: 38, + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + color: active ? DmColors.okSoft : DmColors.brandSoft, + borderRadius: DmRadius.all(DmRadius.xs), + ), + child: Icon( + icon, + size: 18, + color: active ? DmColors.ok : DmColors.brand, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: DmText.cardTitle), + const SizedBox(height: 2), + Text( + subtitle, + style: DmText.small.copyWith( + color: subtitleColor ?? DmColors.ink2, + ), + ), + ], + ), + ), + const Icon( + LucideIcons.chevronRight, + size: 20, + color: DmColors.ink4, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/widgets/pieces.dart b/lib/ui/widgets/pieces.dart new file mode 100644 index 0000000..6522129 --- /dev/null +++ b/lib/ui/widgets/pieces.dart @@ -0,0 +1,559 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../tokens.dart'; +import 'buttons.dart'; +import 'chrome.dart'; + +enum DmTagTone { + /// The default: a quiet sunken chip carrying a fact — "2 Milers nearby". + neutral, + + /// Outlined crimson. The one tag in a group that is a recommendation + /// — "Fastest". + brand, + + /// Kept for the delivered state. + ok, + + warn, + + /// On a crimson field. + onFill, +} + +/// Small badge: "Fastest pickup", "Transit", "4 Milers nearby". +class DmTag extends StatelessWidget { + const DmTag( + this.label, { + super.key, + this.tone = DmTagTone.neutral, + this.icon, + this.mono = false, + }); + + final String label; + final DmTagTone tone; + final IconData? icon; + + /// For a vehicle plate or a reference carried as a tag. + final bool mono; + + @override + Widget build(BuildContext context) { + final (Color bg, Color fg, BoxBorder? edge) = switch (tone) { + DmTagTone.neutral => (DmColors.surfaceAlt, DmColors.ink2, null), + DmTagTone.brand => ( + DmColors.surface, + DmColors.brand, + Border.all(color: DmColors.brandLine), + ), + DmTagTone.ok => (DmColors.okSoft, DmColors.ok, null), + DmTagTone.warn => (const Color(0xFFFFF3DF), const Color(0xFF9C5303), null), + DmTagTone.onFill => (DmColors.onBrandRaise, Colors.white, null), + }; + + return Container( + padding: EdgeInsets.fromLTRB(icon == null ? 10 : 8, 5, 10, 5), + decoration: BoxDecoration( + color: bg, + border: edge, + borderRadius: DmRadius.all(5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 13, color: fg), + const SizedBox(width: 5), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: mono + ? DmText.monoSm.copyWith(fontSize: 12, color: fg) + : DmText.label.copyWith(fontSize: 12, color: fg), + ), + ), + ], + ), + ); + } +} + +/// A selection chip — the design's `.filt`. +/// +/// This is the app's one selection control: it is the sign-in method switch, +/// the address label picker, the slot day filter and the rating tags. Selected +/// is a filled crimson chip with a small glow; unselected is a white chip with +/// a hairline. There is no third state to learn. +class DmChoiceChip extends StatelessWidget { + const DmChoiceChip({ + super.key, + required this.label, + required this.selected, + this.enabled = true, + this.onTap, + this.count, + this.expand = false, + }); + + final String label; + final bool selected; + final bool enabled; + final VoidCallback? onTap; + + /// A figure carried inside the chip, as on the Orders filters. + final int? count; + + /// Fills the available width — for a row of chips that splits evenly. + final bool expand; + + @override + Widget build(BuildContext context) { + final fg = selected + ? Colors.white + : (enabled ? DmColors.ink2 : DmColors.ink4); + + final chip = AnimatedContainer( + duration: DmMotion.base, + curve: DmMotion.ease, + constraints: const BoxConstraints(minHeight: 38), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? DmColors.brand + : (enabled ? DmColors.surface : DmColors.surfaceAlt), + border: Border.all( + color: selected ? DmColors.brand : DmColors.border, + ), + borderRadius: DmRadius.all(DmRadius.xs), + boxShadow: selected ? DmShadow.liftSm : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.label.copyWith(fontSize: 13, color: fg), + ), + ), + if (count != null) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: selected ? const Color(0x3DFFFFFF) : DmColors.surfaceAlt, + borderRadius: DmRadius.all(DmRadius.pill), + ), + child: Text( + '$count', + style: DmText.monoSm.copyWith( + fontSize: 11, + color: selected ? Colors.white : DmColors.ink3, + ), + ), + ), + ], + ], + ), + ); + + final tappable = Semantics( + button: true, + selected: selected, + enabled: enabled, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled + ? () { + HapticFeedback.selectionClick(); + onTap?.call(); + } + : null, + child: chip, + ), + ); + + return expand ? Expanded(child: tappable) : tappable; + } +} + +/// A destination card in a horizontal strip — the design's `.ccard`. +/// +/// Selection is a wash and a crimson edge rather than a fill: a strip of these +/// is scanned, and a solid crimson block in a scrollable row pulls the eye off +/// the neighbours the customer is still comparing. +class DmGridTile extends StatelessWidget { + const DmGridTile({ + super.key, + required this.label, + required this.selected, + this.enabled = true, + this.note, + this.onTap, + this.icon, + }); + + final String label; + final bool selected; + final bool enabled; + final String? note; + final VoidCallback? onTap; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final titleColor = enabled + ? (selected ? DmColors.brand : DmColors.ink) + : DmColors.ink4; + final noteColor = selected + ? DmColors.brandPress + : (enabled ? DmColors.ink3 : DmColors.ink4); + + return Semantics( + button: true, + selected: selected, + enabled: enabled, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: enabled + ? () { + HapticFeedback.selectionClick(); + onTap?.call(); + } + : null, + child: AnimatedContainer( + duration: DmMotion.base, + curve: DmMotion.ease, + padding: const EdgeInsets.fromLTRB(13, 12, 13, 12), + decoration: BoxDecoration( + color: selected + ? DmColors.brandSoft + : (enabled ? DmColors.surface : DmColors.surfaceAlt), + border: Border.all( + color: selected ? DmColors.brand : DmColors.border, + ), + borderRadius: DmRadius.all(DmRadius.md), + ), + child: Row( + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 18, + color: selected ? DmColors.brand : DmColors.ink3, + ), + const SizedBox(width: 10), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: DmText.cardTitle.copyWith(color: titleColor), + ), + if (note != null) ...[ + const SizedBox(height: 2), + Text( + // Secondary: the promise may ellipsize, the place name + // never does. + note!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.tiny.copyWith(fontSize: 11.5, color: noteColor), + ), + ], + ], + ), + ), + if (selected) + const Padding( + padding: EdgeInsets.only(left: 6), + child: Icon(LucideIcons.check, size: 17, color: DmColors.brand), + ), + ], + ), + ), + ), + ); + } +} + +/// "Step 2 of 4 · Pickup" with its segmented rail. +class DmStepHeader extends StatelessWidget { + const DmStepHeader({ + super.key, + required this.step, + required this.total, + required this.label, + }); + + final int step; + final int total; + final String label; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16), + child: DmStepBar( + progress: step / total, + steps: total, + current: step, + label: label, + ), + ); + } +} + +/// A washed strip carrying one piece of supporting information. +class DmInfoBanner extends StatelessWidget { + const DmInfoBanner({ + super.key, + required this.icon, + required this.title, + this.message, + this.tone = DmTagTone.brand, + }); + + final IconData icon; + final String title; + final String? message; + final DmTagTone tone; + + @override + Widget build(BuildContext context) { + final (bg, edge, fg, sub) = switch (tone) { + DmTagTone.ok => (DmColors.okSoft, DmColors.okSoft, DmColors.ok, DmColors.ok), + DmTagTone.warn => ( + const Color(0xFFFFF3DF), + const Color(0xFFF6E3C2), + const Color(0xFF9C5303), + const Color(0xFF9C5303), + ), + DmTagTone.neutral => ( + DmColors.surfaceAlt, + DmColors.border, + DmColors.ink, + DmColors.ink2, + ), + _ => ( + DmColors.brandSoft, + DmColors.brandLine, + DmColors.ink, + DmColors.brandPress, + ), + }; + + return Container( + padding: const EdgeInsets.fromLTRB(13, 13, 14, 13), + decoration: BoxDecoration( + color: bg, + border: Border.all(color: edge), + borderRadius: DmRadius.all(DmRadius.md), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 38, + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + color: tone == DmTagTone.brand ? DmColors.brandSoft : Colors.transparent, + borderRadius: DmRadius.all(DmRadius.xs), + ), + child: Icon( + icon, + size: 18, + color: tone == DmTagTone.brand ? DmColors.brand : fg, + ), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: DmText.cardTitle.copyWith(fontSize: 14, color: fg)), + if (message != null) ...[ + const SizedBox(height: 2), + Text(message!, style: DmText.small.copyWith(color: sub)), + ], + ], + ), + ), + ], + ), + ); + } +} + +/// A reference number with a copy affordance. The number is mono and large +/// enough to read back over a phone. +class DmCopyRow extends StatefulWidget { + const DmCopyRow({ + super.key, + required this.label, + required this.value, + this.trailing, + this.onWash = false, + }); + + final String label; + final String value; + final Widget? trailing; + + /// Sitting inside a wash card, where the label goes crimson. + final bool onWash; + + @override + State createState() => _DmCopyRowState(); +} + +class _DmCopyRowState extends State { + bool _copied = false; + + Future _copy() async { + await Clipboard.setData(ClipboardData(text: widget.value)); + await HapticFeedback.mediumImpact(); + if (!mounted) return; + // The confirmation lands on the control that was pressed rather than in a + // snackbar over the footer: it is the row the customer is looking at. + setState(() => _copied = true); + await Future.delayed(const Duration(milliseconds: 1600)); + if (mounted) setState(() => _copied = false); + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.label.toUpperCase(), + style: DmText.eyebrow.copyWith( + color: widget.onWash ? DmColors.brand : DmColors.ink3, + ), + ), + const SizedBox(height: 4), + Text( + widget.value, + style: DmText.monoLg.copyWith( + color: widget.onWash ? DmColors.brandPress : DmColors.ink, + ), + ), + ], + ), + ), + ?widget.trailing, + DmTextAction( + label: _copied ? 'Copied' : 'Copy', + icon: _copied ? LucideIcons.check : LucideIcons.copy, + onPressed: _copy, + ), + ], + ); + } +} + +/// − n + quantity control. Used for how many packages go to one place. +class DmStepper extends StatelessWidget { + const DmStepper({ + super.key, + required this.value, + required this.onChanged, + this.min = 1, + this.canIncrease = true, + }); + + final int value; + final ValueChanged onChanged; + final int min; + + /// False when a cap has been reached; the + greys out rather than vanishing. + final bool canIncrease; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: DmColors.surface, + border: Border.all(color: DmColors.border), + borderRadius: DmRadius.all(DmRadius.sm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _Step( + icon: LucideIcons.minus, + onTap: value > min ? () => onChanged(value - 1) : null, + ), + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 30), + child: Text( + '$value', + textAlign: TextAlign.center, + style: DmText.monoLg.copyWith(fontSize: 17), + ), + ), + _Step( + icon: LucideIcons.plus, + onTap: canIncrease ? () => onChanged(value + 1) : null, + ), + ], + ), + ); + } +} + +class _Step extends StatelessWidget { + const _Step({required this.icon, this.onTap}); + + final IconData icon; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final enabled = onTap != null; + return Semantics( + button: true, + enabled: enabled, + child: InkResponse( + onTap: enabled + ? () { + HapticFeedback.selectionClick(); + onTap!(); + } + : null, + radius: 20, + child: SizedBox( + width: 44, + height: 44, + child: Icon( + icon, + size: 18, + color: enabled ? DmColors.ink : DmColors.ink4, + ), + ), + ), + ); + } +} diff --git a/lib/ui/widgets/route_map.dart b/lib/ui/widgets/route_map.dart new file mode 100644 index 0000000..4c88e85 --- /dev/null +++ b/lib/ui/widgets/route_map.dart @@ -0,0 +1,460 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +import '../../data/location_service.dart'; +import '../../data/map_config.dart'; +import '../../data/models.dart'; +import '../tokens.dart'; +import 'map_tiles.dart'; + +/// A real map of the journey: pickup and destination joined by the transit +/// path, with the Miler's live position when there is one. +/// +/// Read-only by design — it summarises a booking, and a non-interactive map +/// never steals a scroll gesture from the list it sits in. +class DmRouteMap extends StatefulWidget { + const DmRouteMap({ + super.key, + this.height = 190, + this.pickup, + this.destination, + this.progress = 0, + this.showMiler = false, + this.pickupLabel, + this.destinationLabel, + this.badge, + this.overlay, + this.radius = DmRadius.lg, + }); + + final double height; + + /// Where the Miler collects. Without coordinates the map falls back to the + /// service-area centre rather than showing an empty ocean. + final Place? pickup; + + /// The destination hub. Null draws pickup only — which is exactly right + /// before a district has been chosen. + final Place? destination; + + /// How far along the route the Miler is, 0..1. + final double progress; + final bool showMiler; + + final String? pickupLabel; + final String? destinationLabel; + + /// Pill shown top-left, e.g. "En route · 1.4 km away". + final Widget? badge; + + /// Anything centred over the map, e.g. the "searching" state. + final Widget? overlay; + + final double radius; + + @override + State createState() => _DmRouteMapState(); +} + +class _DmRouteMapState extends State + with TickerProviderStateMixin { + late final AnimationController _pulse = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2000), + )..repeat(); + + late final AnimationController _draw = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..forward(); + + @override + void dispose() { + _pulse.dispose(); + _draw.dispose(); + super.dispose(); + } + + LatLng? get _from => widget.pickup != null && widget.pickup!.hasLocation + ? LatLng(widget.pickup!.lat!, widget.pickup!.lng!) + : null; + + LatLng? get _to => + widget.destination != null && widget.destination!.hasLocation + ? LatLng(widget.destination!.lat!, widget.destination!.lng!) + : null; + + /// A gentle arc rather than a ruler-straight line — it reads as a journey, + /// and it separates the two ends when they are nearly aligned. + List _arc(LatLng a, LatLng b) { + final dLat = b.latitude - a.latitude; + final dLng = b.longitude - a.longitude; + // Perpendicular offset, proportional to the span. + final bowLat = -dLng * 0.12; + final bowLng = dLat * 0.12; + final control = LatLng( + (a.latitude + b.latitude) / 2 + bowLat, + (a.longitude + b.longitude) / 2 + bowLng, + ); + + return [ + for (var i = 0; i <= 48; i++) + _quadratic(a, control, b, i / 48), + ]; + } + + static LatLng _quadratic(LatLng a, LatLng c, LatLng b, double t) { + final u = 1 - t; + return LatLng( + u * u * a.latitude + 2 * u * t * c.latitude + t * t * b.latitude, + u * u * a.longitude + 2 * u * t * c.longitude + t * t * b.longitude, + ); + } + + @override + Widget build(BuildContext context) { + final from = _from; + final to = _to; + final route = (from != null && to != null) ? _arc(from, to) : null; + + return ClipRRect( + borderRadius: DmRadius.all(widget.radius), + child: SizedBox( + height: widget.height, + child: ColoredBox( + color: DmMapTiles.ground, + child: Stack( + children: [ + Positioned.fill( + child: _map(context, from: from, to: to, route: route), + ), + if (widget.badge != null) + Positioned( + top: 12, + left: 12, + // Held to half the map's width so it cannot run under the + // attribution chip opposite it, whatever the badge says. + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.5, + ), + child: widget.badge!, + ), + ), + if (widget.pickupLabel != null) + Positioned( + left: 12, + bottom: 12, + child: _MapLabel( + icon: LucideIcons.circleDot, + color: DmColors.ink, + label: widget.pickupLabel!, + ), + ), + if (widget.destinationLabel != null) + Positioned( + right: 12, + bottom: 12, + child: _MapLabel( + icon: LucideIcons.mapPin, + color: DmColors.brand, + label: widget.destinationLabel!, + ), + ), + // Attribution is required on every map, so it sits top-right, + // clear of the badge (top-left) and the labels (bottom). + const Positioned(top: 10, right: 10, child: DmMapAttribution()), + if (widget.overlay != null) Center(child: widget.overlay!), + ], + ), + ), + ), + ); + } + + Widget _map( + BuildContext context, { + required LatLng? from, + required LatLng? to, + required List? route, + }) { + final centre = + from ?? + to ?? + const LatLng( + LocationService.fallbackLat, + LocationService.fallbackLng, + ); + + // The map itself is built once. Only the layers that animate sit inside an + // AnimatedBuilder, so a pulsing Miler never re-creates the camera. + return FlutterMap( + options: MapOptions( + initialCenter: centre, + initialZoom: to == null ? 13.5 : 11, + initialCameraFit: route == null + ? null + : CameraFit.coordinates( + coordinates: route, + padding: const EdgeInsets.fromLTRB(46, 34, 46, 52), + maxZoom: 14, + ), + backgroundColor: DmMapTiles.ground, + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.none, + ), + ), + children: [ + // The quieter basemap: this map is a summary, the route is the subject. + DmMapTiles.layer(context, style: DmMapStyle.muted), + + if (route != null) + AnimatedBuilder( + animation: _draw, + builder: (context, _) { + final drawn = Curves.easeOutCubic.transform(_draw.value); + final upto = (route.length * drawn).ceil().clamp(2, route.length); + return PolylineLayer( + polylines: [ + Polyline( + points: route.sublist(0, upto), + color: DmColors.brand, + strokeWidth: 4, + borderColor: Colors.white, + borderStrokeWidth: 3, + ), + ], + ); + }, + ), + + AnimatedBuilder( + animation: widget.showMiler + ? Listenable.merge([_pulse, _draw]) + : _draw, + builder: (context, _) { + final drawn = Curves.easeOutCubic.transform(_draw.value); + return MarkerLayer( + markers: [ + if (from != null) + Marker( + point: from, + width: 26, + height: 26, + child: const _OriginDot(), + ), + if (to != null) + Marker( + point: to, + width: 34, + height: 40, + alignment: Alignment.topCenter, + child: const _DestinationPin(), + ), + if (widget.showMiler && route != null) + Marker( + point: route[( + (route.length - 1) * + widget.progress.clamp(0.02, 0.98) * + drawn + ).round()], + width: 58, + height: 58, + child: _MilerDot(pulse: _pulse.value), + ), + ], + ); + }, + ), + ], + ); + } +} + +class _OriginDot extends StatelessWidget { + const _OriginDot(); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all(color: DmColors.ink, width: 3.5), + boxShadow: DmShadow.float, + ), + ), + ); + } +} + +class _DestinationPin extends StatelessWidget { + const _DestinationPin(); + + @override + Widget build(BuildContext context) { + return const Stack( + alignment: Alignment.topCenter, + children: [ + Icon( + LucideIcons.mapPin, + size: 34, + color: DmColors.brand, + shadows: [ + Shadow(color: Color(0x338F0F06), blurRadius: 8, offset: Offset(0, 4)), + ], + ), + Positioned( + top: 9, + child: CircleAvatar(radius: 4, backgroundColor: Colors.white), + ), + ], + ); + } +} + +/// The Miler's live position. The expanding ring is what makes it read as live. +class _MilerDot extends StatelessWidget { + const _MilerDot({required this.pulse}); + + final double pulse; + + @override + Widget build(BuildContext context) { + return Center( + child: Stack( + alignment: Alignment.center, + children: [ + Container( + width: 24 + pulse * 30, + height: 24 + pulse * 30, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: DmColors.brand.withValues(alpha: (1 - pulse) * 0.22), + ), + ), + Container( + width: 26, + height: 26, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: DmShadow.float, + ), + child: const Icon( + LucideIcons.bike, + size: 15, + color: DmColors.brand, + ), + ), + ], + ), + ); + } +} + +class _MapLabel extends StatelessWidget { + const _MapLabel({required this.icon, required this.color, required this.label}); + + final IconData icon; + final Color color; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxWidth: 168), + padding: const EdgeInsets.fromLTRB(9, 6, 12, 6), + decoration: BoxDecoration( + color: DmColors.surface, + border: Border.all(color: DmColors.border), + borderRadius: DmRadius.all(6), + boxShadow: DmShadow.float, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 6), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: DmText.label.copyWith(fontSize: 12), + ), + ), + ], + ), + ); + } +} + +/// Radar sweep shown while the backend is matching a Miler. +class DmRadarPulse extends StatefulWidget { + const DmRadarPulse({super.key, this.size = 150, this.child}); + + final double size; + final Widget? child; + + @override + State createState() => _DmRadarPulseState(); +} + +class _DmRadarPulseState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2600), + )..repeat(); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.size, + height: widget.size, + child: AnimatedBuilder( + animation: _c, + builder: (context, child) { + return Stack( + alignment: Alignment.center, + children: [ + for (var i = 0; i < 3; i++) _ring(i), + ?child, + ], + ); + }, + child: widget.child, + ), + ); + } + + Widget _ring(int index) { + final t = (_c.value + index / 3) % 1; + return Opacity( + opacity: (1 - t) * 0.5, + child: Container( + width: widget.size * (0.35 + t * 0.65), + height: widget.size * (0.35 + t * 0.65), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: DmColors.brand.withValues(alpha: 0.45), + width: 1.5, + ), + ), + ), + ); + } +} diff --git a/lib/ui/widgets/route_rail.dart b/lib/ui/widgets/route_rail.dart new file mode 100644 index 0000000..8cad932 --- /dev/null +++ b/lib/ui/widgets/route_rail.dart @@ -0,0 +1,328 @@ +import 'package:flutter/material.dart'; + +import '../tokens.dart'; + +/// One stop on a rail: where it goes, plus anything worth saying about it. +class DmRailStop { + const DmRailStop({required this.label, this.note, this.trailing}); + + final String label; + + /// Second line under the label. + final String? note; + + /// Right-aligned text, e.g. "2 packages". + final String? trailing; +} + +/// What a node on the thread stands for. +enum DmNodeKind { + /// Where the parcel starts. A hollow circle: a door, not a decision. + origin, + + /// A sorting hub in between. An outlined diamond — the parcel passes + /// through, it does not stop here. + hub, + + /// The destination. A filled crimson diamond, the only solid node on the + /// thread, so the eye lands on where the parcel is going. + drop, + + /// A leg that has not been arranged yet — dashed outline. + pending, +} + +/// Pickup → drop, drawn as a vertical thread. +/// +/// One component for a journey wherever it appears: an order card, the review +/// summary, tracking. It takes a single [drop] or, when a pickup serves several +/// places, a list of [drops] hanging off the same origin. +class DmRouteRail extends StatelessWidget { + const DmRouteRail({ + super.key, + required this.pickup, + this.pickupNote, + this.pickupEyebrow, + this.pickupTrailing, + this.drop, + this.dropNote, + this.dropsEyebrow, + this.drops, + this.compact = false, + this.hub, + this.hubNote, + this.hubTrailing, + }) : assert(drop != null || drops != null, 'give the rail somewhere to go'); + + final String pickup; + final String? pickupNote; + + /// Uppercase micro-label above the origin, e.g. "PICKUP". + final String? pickupEyebrow; + + /// Action rendered at the right of the origin row. + final Widget? pickupTrailing; + + /// The single-destination case. + final String? drop; + final String? dropNote; + + /// Uppercase micro-label above the first destination. + final String? dropsEyebrow; + + /// Several destinations hanging off one pickup. + final List? drops; + + final bool compact; + + /// The sorting hub between pickup and drop, when the customer has been told + /// about it. Omitted on a hyperlocal run, which never sees a hub. + final String? hub; + final String? hubNote; + final Widget? hubTrailing; + + @override + Widget build(BuildContext context) { + final stops = drops ?? [DmRailStop(label: drop!, note: dropNote)]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Leg( + eyebrow: pickupEyebrow, + label: pickup, + note: compact ? null : pickupNote, + trailing: pickupTrailing, + kind: DmNodeKind.origin, + connector: true, + dashedConnector: hub != null, + compact: compact, + ), + if (hub != null) + _Leg( + label: hub!, + note: compact ? null : hubNote, + trailing: hubTrailing, + kind: DmNodeKind.hub, + labelColor: DmColors.brand, + connector: true, + dashedConnector: true, + compact: compact, + ), + for (var i = 0; i < stops.length; i++) + _Leg( + eyebrow: i == 0 ? dropsEyebrow : null, + label: stops[i].label, + note: compact ? null : stops[i].note, + trailingText: stops[i].trailing, + kind: DmNodeKind.drop, + connector: i != stops.length - 1, + compact: compact, + ), + ], + ); + } +} + +class _Leg extends StatelessWidget { + const _Leg({ + required this.label, + required this.note, + required this.kind, + required this.connector, + required this.compact, + this.dashedConnector = false, + this.eyebrow, + this.trailing, + this.trailingText, + this.labelColor, + }); + + final String label; + final String? note; + final DmNodeKind kind; + final bool connector; + final bool dashedConnector; + final bool compact; + final String? eyebrow; + final Widget? trailing; + final String? trailingText; + final Color? labelColor; + + @override + Widget build(BuildContext context) { + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 15, + child: Column( + children: [ + SizedBox(height: eyebrow == null ? 3 : 19), + _Node(kind: kind), + if (connector) + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: CustomPaint( + painter: _Thread(dashed: dashedConnector), + size: const Size(1.5, double.infinity), + ), + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: connector ? (compact ? 13 : 16) : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (eyebrow != null) ...[ + Text(eyebrow!.toUpperCase(), style: DmText.eyebrow), + const SizedBox(height: 4), + ], + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + label, + style: DmText.cardTitle.copyWith(color: labelColor), + ), + ), + if (trailingText != null) ...[ + const SizedBox(width: 10), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: Text( + trailingText!, + textAlign: TextAlign.right, + style: DmText.small, + ), + ), + ], + ?trailing, + ], + ), + if (note != null) ...[ + const SizedBox(height: 3), + Text(note!, style: DmText.small), + ], + ], + ), + ), + ), + ], + ), + ); + } +} + +class _Node extends StatelessWidget { + const _Node({required this.kind}); + + final DmNodeKind kind; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 15, + height: 15, + child: Center( + child: switch (kind) { + DmNodeKind.origin => Container( + width: 11, + height: 11, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: DmColors.ink4, width: 2), + ), + ), + // Rotated squares rather than a second circle: on a thread of three + // identical dots nothing tells you which end matters, and at 11pt a + // diamond is distinguishable from a circle at a glance where two + // shades of the same dot are not. + DmNodeKind.hub => Transform.rotate( + angle: 0.785398, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: DmColors.surface, + borderRadius: BorderRadius.circular(2), + border: Border.all(color: DmColors.brand, width: 2), + ), + ), + ), + DmNodeKind.drop => Transform.rotate( + angle: 0.785398, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: DmColors.brand, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + DmNodeKind.pending => Transform.rotate( + angle: 0.785398, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: DmColors.surface, + borderRadius: BorderRadius.circular(2), + border: Border.all(color: DmColors.ink4, width: 1.5), + ), + ), + ), + }, + ), + ); + } +} + +/// The line between two nodes. Solid where the parcel is handled, dashed +/// across a leg it merely travels — the distinction the design draws between +/// a doorstep and a trunk route. +class _Thread extends CustomPainter { + const _Thread({required this.dashed}); + + final bool dashed; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = DmColors.border + ..strokeWidth = 1.5 + ..strokeCap = StrokeCap.round; + + final x = size.width / 2; + if (!dashed) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); + return; + } + + const dash = 2.0; + const gap = 4.0; + var y = 0.0; + while (y < size.height) { + canvas.drawLine( + Offset(x, y), + Offset(x, (y + dash).clamp(0, size.height)), + paint, + ); + y += dash + gap; + } + } + + @override + bool shouldRepaint(covariant _Thread oldDelegate) => + oldDelegate.dashed != dashed; +} diff --git a/lib/ui/widgets/states.dart b/lib/ui/widgets/states.dart new file mode 100644 index 0000000..9aa7a98 --- /dev/null +++ b/lib/ui/widgets/states.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../tokens.dart'; +import 'buttons.dart'; + +/// Shimmering placeholder rows shown while a remote list loads. +class DmSkeleton extends StatefulWidget { + const DmSkeleton({super.key, this.rows = 4, this.height = 68}); + + final int rows; + final double height; + + @override + State createState() => _DmSkeletonState(); +} + +class _DmSkeletonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _c = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1300), + )..repeat(); + + @override + void dispose() { + _c.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Semantics( + label: 'Loading', + child: AnimatedBuilder( + animation: _c, + builder: (context, _) { + return Column( + children: [ + for (var i = 0; i < widget.rows; i++) + Padding( + padding: EdgeInsets.only( + bottom: i == widget.rows - 1 ? 0 : 10, + ), + child: Container( + height: widget.height, + decoration: BoxDecoration( + borderRadius: DmRadius.all(DmRadius.md), + gradient: LinearGradient( + begin: Alignment(-1 - 2 * (1 - _c.value), 0), + end: Alignment(1 + 2 * (1 - _c.value), 0), + colors: const [ + DmColors.surfaceAlt, + Color(0xFFFBFAF8), + DmColors.surfaceAlt, + ], + stops: const [0.25, 0.4, 0.6], + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +/// Empty / error state with an optional retry action. +class DmEmptyState extends StatelessWidget { + const DmEmptyState({ + super.key, + required this.title, + required this.message, + this.icon = LucideIcons.ban, + this.actionLabel, + this.onAction, + }); + + final String title; + final String message; + final IconData icon; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 40, horizontal: 16), + child: Column( + children: [ + Container( + width: 52, + height: 52, + alignment: Alignment.center, + decoration: BoxDecoration( + color: DmColors.surface, + borderRadius: DmRadius.all(DmRadius.md), + border: Border.all(color: DmColors.border), + ), + child: Icon(icon, size: 24, color: DmColors.ink3), + ), + const SizedBox(height: 16), + Text( + title, + style: DmText.heading, + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Text( + message, + style: DmText.small, + textAlign: TextAlign.center, + ), + if (actionLabel != null) ...[ + const SizedBox(height: 20), + DmButton( + label: actionLabel!, + kind: DmButtonKind.outline, + block: false, + onPressed: onAction, + ), + ], + ], + ), + ); + } +} + +/// Wraps every backend-driven list so loading, empty, unavailable and error + +/// retry are handled identically wherever data comes from. +/// +/// Adding an endpoint needs no extra UI work: return a +/// different [Future] and these states come along. +class DmAsyncList extends StatefulWidget { + const DmAsyncList({ + super.key, + required this.load, + required this.builder, + required this.emptyTitle, + required this.emptyMessage, + this.emptyIcon = LucideIcons.ban, + this.emptyActionLabel = 'Retry', + this.onEmptyAction, + this.skeletonRows = 4, + this.reloadToken, + }); + + /// Called on first build and on every retry. + final Future> Function({bool refresh}) load; + final Widget Function(BuildContext context, List items) builder; + + final String emptyTitle; + final String emptyMessage; + final IconData emptyIcon; + final String emptyActionLabel; + + /// Defaults to reloading; override to send the customer somewhere else. + final VoidCallback? onEmptyAction; + + final int skeletonRows; + + /// Changes when the caller has invalidated what this list is showing. + /// + /// The future is held in state, so a screen the customer returns to would + /// otherwise keep rendering the answer it got the first time — which is how + /// a pickup slot from yesterday stays on screen after the server has + /// rejected it. + final Object? reloadToken; + + @override + State> createState() => DmAsyncListState(); +} + +class DmAsyncListState extends State> { + late Future> _future = widget.load(); + + void reload() { + // Note the block body: setState rejects a callback that returns a value, + // and an expression-bodied closure would hand it the Future. + setState(() { + _future = widget.load(refresh: true); + }); + } + + @override + void didUpdateWidget(covariant DmAsyncList oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.reloadToken != oldWidget.reloadToken) reload(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder>( + future: _future, + builder: (context, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return DmSkeleton(rows: widget.skeletonRows); + } + if (snap.hasError) { + return DmEmptyState( + icon: LucideIcons.wifiOff, + title: "Couldn't load this", + message: _messageFor(snap.error), + actionLabel: 'Retry', + onAction: reload, + ); + } + final items = snap.data ?? const []; + if (items.isEmpty) { + return DmEmptyState( + icon: widget.emptyIcon, + title: widget.emptyTitle, + message: widget.emptyMessage, + actionLabel: widget.emptyActionLabel, + onAction: widget.onEmptyAction ?? reload, + ); + } + return widget.builder(context, items); + }, + ); + } + + String _messageFor(Object? error) { + final text = error?.toString() ?? ''; + final match = RegExp(r'\): (.+)$').firstMatch(text); + return match?.group(1) ?? 'Check your connection and try again.'; + } +} diff --git a/lib/ui/widgets/summary.dart b/lib/ui/widgets/summary.dart new file mode 100644 index 0000000..ea19a40 --- /dev/null +++ b/lib/ui/widgets/summary.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../tokens.dart'; +import 'buttons.dart'; +import 'cards.dart'; + +/// One labelled fact, optionally with an inline edit action. +class DmSummaryRow extends StatelessWidget { + const DmSummaryRow({ + super.key, + required this.label, + required this.value, + this.secondary, + this.muted = false, + this.mono = false, + this.actionLabel, + this.onAction, + }); + + final String label; + final String value; + + /// Smaller grey line under the value (street, postcode…). + final String? secondary; + + /// Renders the value as placeholder text ("Not added — …"). + final bool muted; + + /// Sets the value in mono — references, phone numbers, pincodes. + final bool mono; + + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return DmCardCell( + padding: EdgeInsets.fromLTRB(16, 14, actionLabel == null ? 16 : 10, 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label.toUpperCase(), style: DmText.eyebrow), + const SizedBox(height: 5), + Text( + value, + style: muted + ? DmText.small.copyWith(color: DmColors.ink3) + : mono + ? DmText.mono.copyWith(fontSize: 15, color: DmColors.ink) + : DmText.cardTitle, + ), + if (secondary != null) ...[ + const SizedBox(height: 3), + Text(secondary!, style: DmText.small), + ], + ], + ), + ), + if (actionLabel != null) + DmTextAction(label: actionLabel!, onPressed: onAction), + ], + ), + ); + } +} + +/// White card that rules its children apart — Review, the confirmation summary, +/// and the tracking facts card. +/// +/// A thin wrapper over [DmCard] so the many call sites that say "summary card" +/// keep saying it; new code can use [DmCard] directly and pick a tone. +class DmSummaryCard extends StatelessWidget { + const DmSummaryCard({super.key, required this.children, this.flat = false}); + + final List children; + + /// Sits on the canvas rather than lifting off it. + final bool flat; + + @override + Widget build(BuildContext context) { + return DmCard( + tone: flat ? DmCardTone.sunken : DmCardTone.plain, + children: children, + ); + } +} + +/// Quiet explanatory note — the "this creates a pickup request" block. +class DmNote extends StatelessWidget { + const DmNote({super.key, required this.child, this.icon = LucideIcons.info}); + + final Widget child; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(14, 14, 15, 14), + decoration: BoxDecoration( + color: DmColors.surfaceAlt, + border: Border.all(color: DmColors.border), + borderRadius: DmRadius.all(DmRadius.md), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 17, color: DmColors.ink3), + const SizedBox(width: 11), + Expanded(child: child), + ], + ), + ); + } +} + +/// Confirmation strip — "Delivering to Coimbatore, Tamil Nadu". +/// +/// Crimson, not green. The app has one accent, and a confirmation inside the +/// booking flow is a brand moment, not a system status: green here would be +/// the only green in the product and would read as a validation message. +class DmConfirmedStrip extends StatelessWidget { + const DmConfirmedStrip({super.key, required this.prefix, required this.value}); + + final String prefix; + final String value; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(13, 12, 15, 12), + decoration: BoxDecoration( + color: DmColors.brandSoft, + border: Border.all(color: DmColors.brandLine), + borderRadius: DmRadius.all(DmRadius.md), + ), + child: Row( + children: [ + Container( + width: 22, + height: 22, + alignment: Alignment.center, + decoration: const BoxDecoration( + color: DmColors.brand, + shape: BoxShape.circle, + ), + child: const Icon(LucideIcons.check, size: 13, color: Colors.white), + ), + const SizedBox(width: 11), + Expanded( + child: Text.rich( + TextSpan( + style: DmText.small.copyWith(color: DmColors.brandPress), + children: [ + TextSpan(text: prefix), + TextSpan( + text: value, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/macos/.gitignore b/macos/.gitignore deleted file mode 100644 index 746adbb..0000000 --- a/macos/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Flutter-related -**/Flutter/ephemeral/ -**/Pods/ - -# Xcode-related -**/dgph -**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index c2efd0b..0000000 --- a/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index c2efd0b..0000000 --- a/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index cb9ba9f..0000000 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,24 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import firebase_auth -import firebase_core -import firebase_messaging -import geolocator_apple -import package_info_plus -import shared_preferences_foundation -import url_launcher_macos - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) - FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) - FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) - GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) - FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) -} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 37f10db..0000000 --- a/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,729 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC10EC2044A3C60003C045; - remoteInfo = Runner; - }; - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* doormile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "doormile.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 331C80D2294CF70F00263BE5 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C80D6294CF71000263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C80D7294CF71000263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 331C80D6294CF71000263BE5 /* RunnerTests */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* doormile.app */, - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C80D4294CF70F00263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C80D1294CF70F00263BE5 /* Sources */, - 331C80D2294CF70F00263BE5 /* Frameworks */, - 331C80D3294CF70F00263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C80DA294CF71000263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 33CC10EC2044A3C60003C045 /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - 3399D490228B24CF009A79C7 /* ShellScript */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* doormile.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C80D4294CF70F00263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 33CC10EC2044A3C60003C045; - }; - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, - ); - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 331C80D4294CF70F00263BE5 /* RunnerTests */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C80D3294CF70F00263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C80D1294CF70F00263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC10EC2044A3C60003C045 /* Runner */; - targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; - }; - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 331C80DB294CF71000263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/doormile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/doormile"; - }; - name = Debug; - }; - 331C80DC294CF71000263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/doormile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/doormile"; - }; - name = Release; - }; - 331C80DD294CF71000263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/doormile.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/doormile"; - }; - name = Profile; - }; - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C80DB294CF71000263BE5 /* Debug */, - 331C80DC294CF71000263BE5 /* Release */, - 331C80DD294CF71000263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; - }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { - isa = XCSwiftPackageProductDependency; - productName = FlutterGeneratedPluginSwiftPackage; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index a58741a..0000000 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a1..0000000 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift deleted file mode 100644 index b3c1761..0000000 --- a/macos/Runner/AppDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Cocoa -import FlutterMacOS - -@main -class AppDelegate: FlutterAppDelegate { - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - - override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - return true - } -} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index a2ec33f..0000000 --- a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" - }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png deleted file mode 100644 index 82b6f9d..0000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png deleted file mode 100644 index 13b35eb..0000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png deleted file mode 100644 index 0a3f5fa..0000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png deleted file mode 100644 index bdb5722..0000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png deleted file mode 100644 index f083318..0000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png deleted file mode 100644 index 326c0e7..0000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and /dev/null differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png deleted file mode 100644 index 2f1632c..0000000 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and /dev/null differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib deleted file mode 100644 index 80e867a..0000000 --- a/macos/Runner/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig deleted file mode 100644 index 40af5b9..0000000 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. - -// The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = doormile - -// The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.example.doormile - -// The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig deleted file mode 100644 index 36b0fd9..0000000 --- a/macos/Runner/Configs/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Debug.xcconfig" -#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig deleted file mode 100644 index dff4f49..0000000 --- a/macos/Runner/Configs/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Release.xcconfig" -#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig deleted file mode 100644 index 42bcbf4..0000000 --- a/macos/Runner/Configs/Warnings.xcconfig +++ /dev/null @@ -1,13 +0,0 @@ -WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings -GCC_WARN_UNDECLARED_SELECTOR = YES -CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES -CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE -CLANG_WARN__DUPLICATE_METHOD_MATCH = YES -CLANG_WARN_PRAGMA_PACK = YES -CLANG_WARN_STRICT_PROTOTYPES = YES -CLANG_WARN_COMMA = YES -GCC_WARN_STRICT_SELECTOR_MATCH = YES -CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES -CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES -GCC_WARN_SHADOW = YES -CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements deleted file mode 100644 index dddb8a3..0000000 --- a/macos/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - - diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist deleted file mode 100644 index 4789daa..0000000 --- a/macos/Runner/Info.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - - diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift deleted file mode 100644 index 3cc05eb..0000000 --- a/macos/Runner/MainFlutterWindow.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Cocoa -import FlutterMacOS - -class MainFlutterWindow: NSWindow { - override func awakeFromNib() { - let flutterViewController = FlutterViewController() - let windowFrame = self.frame - self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) - - RegisterGeneratedPlugins(registry: flutterViewController) - - super.awakeFromNib() - } -} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements deleted file mode 100644 index 852fa1a..0000000 --- a/macos/Runner/Release.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.app-sandbox - - - diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift deleted file mode 100644 index 61f3bd1..0000000 --- a/macos/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Cocoa -import FlutterMacOS -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/pubspec.lock b/pubspec.lock index 232080b..dd5f08c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,30 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "0d1f0adfabbab9f46a1a80ce84a4d8b852b6e4dbf53ce413b30e0cf7d3631b71" - url: "https://pub.dev" - source: hosted - version: "1.3.72" - ansicolor: - dependency: transitive - description: - name: ansicolor - sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" - url: "https://pub.dev" - source: hosted - version: "2.0.3" archive: dependency: transitive description: name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + sha256: ace891da0862b0e4cabbb064ee3fd87b2728b898949fdb366d83fe98342c9f19 url: "https://pub.dev" source: hosted - version: "4.0.9" + version: "4.2.0" args: dependency: transitive description: @@ -57,30 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" - checked_yaml: - dependency: transitive - description: - name: checked_yaml - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" - source: hosted - version: "2.0.4" - cli_util: - dependency: transitive - description: - name: cli_util - sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c - url: "https://pub.dev" - source: hosted - version: "0.4.2" clock: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -97,14 +73,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - csslib: - dependency: transitive - description: - name: csslib - sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" - url: "https://pub.dev" - source: hosted - version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -113,14 +81,30 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" dbus: dependency: transitive description: name: dbus - sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 url: "https://pub.dev" source: hosted - version: "0.7.14" + version: "0.7.15" fake_async: dependency: transitive description: @@ -137,14 +121,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - ffi_leak_tracker: - dependency: transitive - description: - name: ffi_leak_tracker - sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" - url: "https://pub.dev" - source: hosted - version: "0.1.2" file: dependency: transitive description: @@ -153,78 +129,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" - firebase_auth: - dependency: "direct main" - description: - name: firebase_auth - sha256: "9905a315f1235a649e29df19b246adde7350a11234837cd605254b8e25144711" - url: "https://pub.dev" - source: hosted - version: "6.5.2" - firebase_auth_platform_interface: - dependency: transitive - description: - name: firebase_auth_platform_interface - sha256: f757dc5636ce2b204a82383f7246ef0d9c925f9e96c8c9a4a6c315d673d662bb - url: "https://pub.dev" - source: hosted - version: "9.0.2" - firebase_auth_web: - dependency: transitive - description: - name: firebase_auth_web - sha256: "1c44330eef3c82068e0c2919b6b015897d49211dcccdf64f90a2ed73ce216ecb" - url: "https://pub.dev" - source: hosted - version: "6.2.2" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: ec46a100a560d3bd5f97f2d89ba7492cb09b6dd0a4a28753d1258f360d6bd9f9 - url: "https://pub.dev" - source: hosted - version: "4.10.0" - firebase_core_platform_interface: - dependency: transitive - description: - name: firebase_core_platform_interface - sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b" - url: "https://pub.dev" - source: hosted - version: "7.0.1" - firebase_core_web: - dependency: transitive - description: - name: firebase_core_web - sha256: "5ad1be848692ec148f2d6a8ad2a3838cb852ea5f3c9e6479a7afce479e1854f8" - url: "https://pub.dev" - source: hosted - version: "3.8.0" - firebase_messaging: - dependency: "direct main" - description: - name: firebase_messaging - sha256: "9651e833454156b9f0927eaedccffba0f7f6a6e20ceddef82517211e7017e9c4" - url: "https://pub.dev" - source: hosted - version: "16.3.0" - firebase_messaging_platform_interface: - dependency: transitive - description: - name: firebase_messaging_platform_interface - sha256: "292bb5dc9c4a429078895406c347d7c7690deb858c1adeed8f4b4346f769dfa3" - url: "https://pub.dev" - source: hosted - version: "4.8.0" - firebase_messaging_web: - dependency: transitive - description: - name: firebase_messaging_web - sha256: "08c565bc83729439f5de2dfea77b4832002b705eb2f840366cefa80e4e5c3c66" - url: "https://pub.dev" - source: hosted - version: "4.2.0" fixnum: dependency: transitive description: @@ -243,38 +147,70 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_launcher_icons: - dependency: "direct dev" - description: - name: flutter_launcher_icons - sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" - url: "https://pub.dev" - source: hosted - version: "0.14.4" flutter_lints: dependency: "direct dev" description: name: flutter_lints - sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "4.0.0" - flutter_native_splash: - dependency: "direct dev" + version: "6.0.0" + flutter_map: + dependency: "direct main" description: - name: flutter_native_splash - sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002" + name: flutter_map + sha256: "9bbe5472f66ae648d2ca2efbce97fcaa04e71261fabd3e062f26728222d7f767" url: "https://pub.dev" source: hosted - version: "2.4.7" - flutter_plugin_android_lifecycle: + version: "8.3.2" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: dependency: transitive description: - name: flutter_plugin_android_lifecycle - sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 url: "https://pub.dev" source: hosted - version: "2.0.35" + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -298,38 +234,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.1" - geocoding: - dependency: "direct main" - description: - name: geocoding - sha256: "606be036287842d779d7ec4e2f6c9435fc29bbbd3c6da6589710f981d8852895" - url: "https://pub.dev" - source: hosted - version: "4.0.0" - geocoding_android: - dependency: transitive - description: - name: geocoding_android - sha256: ba810da90d6633cbb82bbab630e5b4a3b7d23503263c00ae7f1ef0316dcae5b9 - url: "https://pub.dev" - source: hosted - version: "4.0.1" - geocoding_ios: - dependency: transitive - description: - name: geocoding_ios - sha256: "18ab1c8369e2b0dcb3a8ccc907319334f35ee8cf4cfef4d9c8e23b13c65cb825" - url: "https://pub.dev" - source: hosted - version: "3.1.0" - geocoding_platform_interface: - dependency: transitive - description: - name: geocoding_platform_interface - sha256: "8c2c8226e5c276594c2e18bfe88b19110ed770aeb7c1ab50ede570be8b92229b" - url: "https://pub.dev" - source: hosted - version: "3.2.0" geolocator: dependency: "direct main" description: @@ -342,42 +246,42 @@ packages: dependency: transitive description: name: geolocator_android - sha256: "179c3cb66dfa674fc9ccbf2be872a02658724d1c067634e2c427cf6df7df901a" + sha256: "86ea1654e4f61ff51466848e91c116b422d6010ea269fda0fbe1af7e9e742ce1" url: "https://pub.dev" source: hosted - version: "5.0.2" + version: "5.0.3" geolocator_apple: dependency: transitive description: name: geolocator_apple - sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" url: "https://pub.dev" source: hosted - version: "2.3.13" + version: "2.3.14" geolocator_linux: dependency: transitive description: name: geolocator_linux - sha256: b02993d59b753bf1967ed28862002841aec5cdd5090c1f495213b32ce488aced + sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 url: "https://pub.dev" source: hosted - version: "0.2.5" + version: "0.2.4" geolocator_platform_interface: dependency: transitive description: name: geolocator_platform_interface - sha256: dde05dae7d584db6e82feb87dd9fb0b4b4c83ed68065667b4bef637be38e13a7 + sha256: "94db8255dc183d268765df682580440617ca35877fc82cacb5420ad03b86198d" url: "https://pub.dev" source: hosted - version: "4.2.7" + version: "4.3.0" geolocator_web: dependency: transitive description: name: geolocator_web - sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" url: "https://pub.dev" source: hosted - version: "4.1.3" + version: "4.1.4" geolocator_windows: dependency: transitive description: @@ -386,54 +290,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" - 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: f4040715c2998144a46cfc0fd91ee83226655af069dfed3d2b84552dc5e4facb - url: "https://pub.dev" - source: hosted - version: "2.17.1" - google_maps_flutter_android: - dependency: transitive - description: - name: google_maps_flutter_android - sha256: "06c9c886e887dd61b9a8728c43344e58071279182bbc7a3c4001d322fc3cd111" - url: "https://pub.dev" - source: hosted - version: "2.19.10" - google_maps_flutter_ios: - dependency: transitive - description: - name: google_maps_flutter_ios - sha256: f573749a75c4074b7a74ab986703607e9cd859fb2807c332872620f3656400dc - url: "https://pub.dev" - source: hosted - version: "2.18.3" - google_maps_flutter_platform_interface: - dependency: transitive - description: - name: google_maps_flutter_platform_interface - sha256: ddbe34435dfb34e83fca295c6a8dcc53c3b51487e9eec3c737ce4ae605574347 - url: "https://pub.dev" - source: hosted - version: "2.15.0" - google_maps_flutter_web: - dependency: transitive - description: - name: google_maps_flutter_web - sha256: e23f2869449bb3e2ed7cfdb178d716e1da7edc684363987e4cb2c42f2bd7a9e0 - url: "https://pub.dev" - source: hosted - version: "0.6.2+3" gsettings: dependency: transitive description: @@ -442,14 +298,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.8" - html: + hooks: dependency: transitive description: - name: html - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" url: "https://pub.dev" source: hosted - version: "0.15.6" + version: "2.0.2" http: dependency: "direct main" description: @@ -466,27 +322,59 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - image: - dependency: transitive - description: - name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce - url: "https://pub.dev" - source: hosted - version: "4.8.0" integration_test: dependency: "direct dev" description: flutter source: sdk version: "0.0.0" - json_annotation: + intl: dependency: transitive description: - name: json_annotation - sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" + name: intl + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "4.12.0" + version: "0.20.3" + jni: + dependency: transitive + description: + name: jni + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "84b776b100a84a684a0aaa3fa4dc2c9b1931c658de533701ee4e09bfa991f8dc" + url: "https://pub.dev" + source: hosted + version: "0.10.1" leak_tracker: dependency: transitive description: @@ -515,18 +403,34 @@ packages: dependency: transitive description: name: lints - sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235" + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + lucide_icons_flutter: + dependency: "direct main" + description: + name: lucide_icons_flutter + sha256: "3d06144ccc67c29fd080704ee59b6e1927f67654a848443eceb130b2e425e080" + url: "https://pub.dev" + source: hosted + version: "3.1.19" matcher: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -539,34 +443,50 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d url: "https://pub.dev" source: hosted - version: "1.18.0" - nested: + version: "1.18.3" + mgrs_dart: dependency: transitive description: - name: nested - sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + name: mgrs_dart + sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f" url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "3.0.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" + source: hosted + version: "9.5.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d + url: "https://pub.dev" + source: hosted + version: "3.0.0" package_info_plus: dependency: transitive description: name: package_info_plus - sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852" + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" url: "https://pub.dev" source: hosted - version: "10.1.0" + version: "9.0.1" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" url: "https://pub.dev" source: hosted - version: "4.1.0" + version: "3.2.1" path: dependency: transitive description: @@ -575,22 +495,46 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: @@ -599,54 +543,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - permission_handler: - dependency: "direct main" - description: - name: permission_handler - sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 - url: "https://pub.dev" - source: hosted - version: "12.0.3" - permission_handler_android: - dependency: transitive - description: - name: permission_handler_android - sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" - url: "https://pub.dev" - source: hosted - version: "13.0.1" - permission_handler_apple: - dependency: transitive - description: - name: permission_handler_apple - sha256: e20daf680eef1ca62ffe8c8c526b778cc386d50137c77ac71c8ec9c88c13fb9d - url: "https://pub.dev" - source: hosted - version: "9.4.9" - permission_handler_html: - dependency: transitive - description: - name: permission_handler_html - sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" - url: "https://pub.dev" - source: hosted - version: "0.1.3+5" - permission_handler_platform_interface: - dependency: transitive - description: - name: permission_handler_platform_interface - sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 - url: "https://pub.dev" - source: hosted - version: "4.3.0" - permission_handler_windows: - dependency: transitive - description: - name: permission_handler_windows - sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" - url: "https://pub.dev" - source: hosted - version: "0.2.1" petitparser: dependency: transitive description: @@ -655,14 +551,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" - pin_input_text_field: - dependency: transitive - description: - name: pin_input_text_field - sha256: f45683032283d30b670ec343781660655e3e1953438b281a0bc6e2d358486236 - url: "https://pub.dev" - source: hosted - version: "4.5.2" platform: dependency: transitive description: @@ -683,103 +571,55 @@ packages: dependency: transitive description: name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + sha256: bc1bad54ad2b735816e31f8d4600cfde6c7839975085ddfbca48b6c9f7c4044e url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.5.2" process: dependency: transitive description: name: process - sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" url: "https://pub.dev" source: hosted - version: "5.0.5" - provider: - dependency: "direct main" - description: - name: provider - sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" - url: "https://pub.dev" - source: hosted - version: "6.1.5+1" - sanitize_html: + version: "5.0.6" + proj4dart: dependency: transitive description: - name: sanitize_html - sha256: "12669c4a913688a26555323fb9cec373d8f9fbe091f2d01c40c723b33caa8989" + name: proj4dart + sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859 url: "https://pub.dev" source: hosted - version: "2.1.0" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf - url: "https://pub.dev" - source: hosted - version: "2.5.5" - shared_preferences_android: + version: "3.0.0" + pub_semver: dependency: transitive description: - name: shared_preferences_android - sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + name: pub_semver + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.4.23" - shared_preferences_foundation: + version: "2.2.1" + record_use: dependency: transitive description: - name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" url: "https://pub.dev" source: hosted - version: "2.5.6" - shared_preferences_linux: + version: "0.6.0" + simple_sparse_list: dependency: transitive description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f url: "https://pub.dev" source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.dev" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.dev" - source: hosted - version: "2.4.1" + version: "0.1.4" sky_engine: dependency: transitive description: flutter source: sdk version: "0.0.0" - sms_autofill: - dependency: "direct main" - description: - name: sms_autofill - sha256: "5c6c5569a310fce12117eaefab5be6e29cc676b635e43be8b70b1946ec21ae91" - url: "https://pub.dev" - source: hosted - version: "2.5.0" source_span: dependency: transitive description: @@ -792,10 +632,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" stream_channel: dependency: transitive description: @@ -804,14 +644,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: @@ -840,10 +672,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" typed_data: dependency: transitive description: @@ -852,102 +684,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" - universal_io: + unicode: dependency: transitive description: - name: universal_io - sha256: f63cbc48103236abf48e345e07a03ce5757ea86285ed313a6a032596ed9301e2 + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 url: "https://pub.dev" source: hosted - version: "2.3.1" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.dev" - source: hosted - version: "6.3.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" - url: "https://pub.dev" - source: hosted - version: "6.3.30" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" - url: "https://pub.dev" - source: hosted - version: "6.4.1" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.dev" - source: hosted - version: "3.2.2" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" - url: "https://pub.dev" - source: hosted - version: "3.2.5" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" - url: "https://pub.dev" - source: hosted - version: "2.4.3" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.dev" - source: hosted - version: "3.1.5" + version: "1.1.9" uuid: dependency: transitive description: name: uuid - sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" url: "https://pub.dev" source: hosted - version: "4.5.3" + version: "4.6.0" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.0" vm_service: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" url: "https://pub.dev" source: hosted - version: "15.2.0" + version: "15.3.0" web: dependency: transitive description: @@ -956,22 +724,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: "direct main" - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" - source: hosted - version: "3.0.3" webdriver: dependency: transitive description: @@ -984,10 +736,18 @@ packages: dependency: transitive description: name: win32 - sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "5.15.0" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: @@ -1000,18 +760,18 @@ packages: dependency: transitive description: name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" url: "https://pub.dev" source: hosted - version: "6.6.1" + version: "7.0.1" yaml: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" sdks: - dart: ">=3.10.0 <4.0.0" - flutter: ">=3.38.1" + dart: ">=3.13.2 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml index 54b1b39..68b6f5e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,50 +1,133 @@ -name: doormile -description: "Doormile — EV-first parcel delivery app." -publish_to: 'none' -version: 1.0.3+5 +name: doormile_cx +description: "Doormile — book a pickup from your door, track it to delivery." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 environment: - sdk: ">=3.0.0 <4.0.0" + sdk: ^3.13.2 +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - cupertino_icons: ^1.0.6 - provider: ^6.1.2 - firebase_core: ^4.10.0 - firebase_auth: ^6.5.2 - http: ^1.6.0 - shared_preferences: ^2.5.5 - permission_handler: ^12.0.3 - geolocator: ^14.0.2 - url_launcher: ^6.3.2 - geocoding: ^4.0.0 - google_maps_flutter: ^2.9.0 - web_socket_channel: ^3.0.1 - firebase_messaging: 16.3.0 - sms_autofill: ^2.3.0 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + + # Lucide — the icon set the whole UI draws from. + # + # Material's stock glyphs are heavy, inconsistently weighted and instantly + # recognisable as "default Flutter". Lucide is a single 1.5px-stroke family, + # so an icon sits at the same visual weight as the type beside it instead of + # shouting over it. Same set the Miler rider app uses, so the two apps in + # this repo read as one product. + lucide_icons_flutter: ^3.1.17 + flutter_map: ^8.3.2 + latlong2: ^0.10.1 + # Pinned below 14.0.3: that patch pulls geolocator_linux 0.2.6, which pins + # win32 ^6 through package_info_plus and makes flutter_secure_storage 9.x + # unsolvable. 10/11.x of that package need compileSdk 37, which this + # project's Android Gradle plugin does not support. Neither desktop plugin + # ships here; the constraint is pub's arithmetic, not a runtime need. + geolocator: 14.0.2 + + # Networking. `http` is the whole transport — every call goes through + # `ApiClient`, which is the only place in the app that knows about it. + http: ^1.5.0 + + # Session persistence. Refresh tokens live 60 days and rotate on every use, + # so they belong in the keychain / EncryptedSharedPreferences, not in a + # plaintext preferences file. + flutter_secure_storage: ^9.2.4 dev_dependencies: flutter_test: sdk: flutter integration_test: sdk: flutter - flutter_lints: ^4.0.0 - flutter_launcher_icons: ^0.14.4 - flutter_native_splash: ^2.4.7 + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. uses-material-design: true + + # Both families ship as a single variable-font file rather than one file per + # weight: Manrope is used from 500 to 800 and Geist Mono at 500, which as + # static cuts would be six font files and roughly 2 MB in the bundle. The + # weight is selected in `DmText` through the `wght` axis (`fontVariations`), + # not by Flutter picking a named instance. fonts: - - family: Public Sans + - family: Manrope fonts: - - asset: assets/fonts/PublicSans.ttf + - asset: assets/fonts/Manrope-VF.ttf + - family: GeistMono + fonts: + - asset: assets/fonts/GeistMono-VF.ttf assets: - assets/images/ -flutter_launcher_icons: - android: "launcher_icon" - ios: true - image_path: "assets/images/launcher_doormile.png" + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/screenshots/01_login.png b/screenshots/01_login.png new file mode 100644 index 0000000..8eed9a7 Binary files /dev/null and b/screenshots/01_login.png differ diff --git a/screenshots/02_otp.png b/screenshots/02_otp.png new file mode 100644 index 0000000..6645c3b Binary files /dev/null and b/screenshots/02_otp.png differ diff --git a/screenshots/03_home.png b/screenshots/03_home.png new file mode 100644 index 0000000..e141c9c Binary files /dev/null and b/screenshots/03_home.png differ diff --git a/screenshots/04_pickup.png b/screenshots/04_pickup.png new file mode 100644 index 0000000..d88dbe6 Binary files /dev/null and b/screenshots/04_pickup.png differ diff --git a/screenshots/05_destination.png b/screenshots/05_destination.png new file mode 100644 index 0000000..1e866ec Binary files /dev/null and b/screenshots/05_destination.png differ diff --git a/screenshots/06_destination_filled.png b/screenshots/06_destination_filled.png new file mode 100644 index 0000000..7d0e80a Binary files /dev/null and b/screenshots/06_destination_filled.png differ diff --git a/screenshots/07_slot.png b/screenshots/07_slot.png new file mode 100644 index 0000000..6beb564 Binary files /dev/null and b/screenshots/07_slot.png differ diff --git a/screenshots/08_review.png b/screenshots/08_review.png new file mode 100644 index 0000000..07805df Binary files /dev/null and b/screenshots/08_review.png differ diff --git a/screenshots/09_confirmed.png b/screenshots/09_confirmed.png new file mode 100644 index 0000000..3d65c0a Binary files /dev/null and b/screenshots/09_confirmed.png differ diff --git a/screenshots/10_tracking.png b/screenshots/10_tracking.png new file mode 100644 index 0000000..93938b5 Binary files /dev/null and b/screenshots/10_tracking.png differ diff --git a/screenshots/11_orders.png b/screenshots/11_orders.png new file mode 100644 index 0000000..b789693 Binary files /dev/null and b/screenshots/11_orders.png differ diff --git a/screenshots/12_account.png b/screenshots/12_account.png new file mode 100644 index 0000000..51c5378 Binary files /dev/null and b/screenshots/12_account.png differ diff --git a/store/play_feature_graphic_1024x500.png b/store/play_feature_graphic_1024x500.png new file mode 100644 index 0000000..26de5c9 Binary files /dev/null and b/store/play_feature_graphic_1024x500.png differ diff --git a/store/play_store_icon_512.png b/store/play_store_icon_512.png new file mode 100644 index 0000000..8c2fd8e Binary files /dev/null and b/store/play_store_icon_512.png differ diff --git a/test/api_client_test.dart b/test/api_client_test.dart new file mode 100644 index 0000000..c7b690d --- /dev/null +++ b/test/api_client_test.dart @@ -0,0 +1,432 @@ +import 'dart:convert'; + +import 'package:doormile_cx/data/api_client.dart'; +import 'package:doormile_cx/data/api_exception.dart'; +import 'package:doormile_cx/data/session_store.dart'; +import 'package:doormile_cx/data/models.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// Records what the client actually put on the wire. +class _Recorder { + final List requests = []; +} + +MockClient _client( + _Recorder log, + http.Response Function(http.Request request, int callIndex) respond, +) { + var calls = 0; + return MockClient((request) async { + log.requests.add(request); + return respond(request, calls++); + }); +} + +http.Response _ok(Object data, {Map? headers}) => http.Response( + jsonEncode({'success': true, 'data': data, 'message': ''}), + 200, + headers: {'content-type': 'application/json', ...?headers}, +); + +http.Response _fail(int status, String code, String message) => http.Response( + jsonEncode({ + 'success': false, + 'message': message, + 'error': {'code': code}, + }), + status, + headers: {'content-type': 'application/json', 'x-request-id': 'req_42'}, +); + +Session _session({ + String access = 'access-1', + String refresh = 'refresh-1', + Duration life = const Duration(hours: 1), +}) => Session( + accessToken: access, + refreshToken: refresh, + expiresAt: DateTime.now().add(life), +); + +void main() { + group('envelope and headers', () { + test('unwraps data and sends the client identity on every request', () async { + final log = _Recorder(); + final client = ApiClient( + httpClient: _client(log, (_, _) => _ok({'ok': true})), + sessions: MemorySessionStore(), + ); + + final response = await client.get('/config/booking-limits'); + + expect(response.map['ok'], isTrue); + final sent = log.requests.single; + expect(sent.headers['X-Client'], startsWith('doormile-cx/')); + expect(sent.headers['X-Platform'], isNotEmpty); + expect(sent.url.path, endsWith('/customer/config/booking-limits')); + }); + + test('sends the bearer token when there is a session', () async { + final log = _Recorder(); + final sessions = MemorySessionStore(); + await sessions.write(_session()); + final client = ApiClient( + httpClient: _client(log, (_, _) => _ok(const {})), + sessions: sessions, + ); + + await client.get('/auth/me'); + + expect(log.requests.single.headers['Authorization'], 'Bearer access-1'); + }); + + test('carries the idempotency key given to it', () async { + final log = _Recorder(); + final client = ApiClient( + httpClient: _client(log, (_, _) => _ok(const {})), + sessions: MemorySessionStore(), + ); + + await client.post('/bookings', body: const {}, idempotencyKey: 'key-1'); + + expect(log.requests.single.headers['Idempotency-Key'], 'key-1'); + }); + + test('a fresh idempotency key is never the same twice', () { + final client = ApiClient( + httpClient: _client(_Recorder(), (_, _) => _ok(const {})), + sessions: MemorySessionStore(), + ); + final keys = List.generate(50, (_) => client.newIdempotencyKey()); + expect(keys.toSet().length, 50); + }); + }); + + group('error mapping', () { + test('maps every contract status onto its code', () async { + const cases = { + 400: ApiException.invalid, + 401: ApiException.unauthorized, + 403: ApiException.forbidden, + 404: ApiException.notFound, + 409: ApiException.conflict, + 422: ApiException.unserviceable, + 429: ApiException.rateLimited, + 500: ApiException.serverError, + }; + + for (final entry in cases.entries) { + final client = ApiClient( + httpClient: MockClient( + (_) async => http.Response( + jsonEncode({'success': false, 'message': 'nope'}), + entry.key, + ), + ), + sessions: MemorySessionStore(), + ); + await expectLater( + // 401 without a session must not be retried as a refresh. + client.get('/bookings', authenticated: false), + throwsA( + isA().having((e) => e.code, 'code', entry.value), + ), + ); + } + }); + + test("the server's own code wins over the status default", () async { + final client = ApiClient( + httpClient: MockClient( + (_) async => _fail(400, ApiException.invalidName, 'Enter your full name'), + ), + sessions: MemorySessionStore(), + ); + + await expectLater( + client.post('/auth/signup', body: const {}, authenticated: false), + throwsA( + isA() + .having((e) => e.code, 'code', ApiException.invalidName) + .having((e) => e.message, 'message', 'Enter your full name') + .having((e) => e.requestId, 'requestId', 'req_42'), + ), + ); + }); + + test('an HTML error page is a network failure, not a parse crash', () async { + final client = ApiClient( + httpClient: MockClient( + (_) async => http.Response('502 Bad Gateway', 502), + ), + sessions: MemorySessionStore(), + ); + + await expectLater( + client.get('/bookings', authenticated: false), + throwsA( + isA().having( + (e) => e.code, + 'code', + ApiException.serverError, + ), + ), + ); + }); + + test('a stale slot is recognised however the server phrases it', () { + expect( + ApiException(ApiException.conflict, 'That pickup window just filled up') + .needsFreshSlots, + isTrue, + ); + expect( + ApiException( + ApiException.invalid, + 'That pickup time has passed — pick a new slot', + ).needsFreshSlots, + isTrue, + ); + expect( + ApiException(ApiException.invalid, 'Enter your full name') + .needsFreshSlots, + isFalse, + ); + }); + }); + + group('retries', () { + test('retries a GET through a transient failure', () async { + final log = _Recorder(); + final client = ApiClient( + httpClient: _client( + log, + (_, call) => + call < 2 ? http.Response('', 503) : _ok(const {'ok': true}), + ), + sessions: MemorySessionStore(), + ); + + final response = await client.get('/serviceability/states'); + + expect(response.map['ok'], isTrue); + expect(log.requests.length, 3); + }); + + test('never replays a POST — a second booking is worse than an error', () async { + final log = _Recorder(); + final client = ApiClient( + httpClient: _client(log, (_, _) => http.Response('', 503)), + sessions: MemorySessionStore(), + ); + + await expectLater( + client.post('/bookings', body: const {}), + throwsA(isA()), + ); + expect(log.requests.length, 1); + }); + }); + + group('ETag', () { + test('replays the tag and reports a 304 rather than empty data', () async { + final log = _Recorder(); + final client = ApiClient( + httpClient: _client( + log, + (_, call) => call == 0 + ? _ok(const [], headers: {'etag': 'W/"v1"'}) + : http.Response('', 304, headers: {'etag': 'W/"v1"'}), + ), + sessions: MemorySessionStore(), + ); + + final first = await client.get('/serviceability/states'); + expect(first.etag, 'W/"v1"'); + expect(first.notModified, isFalse); + + final second = await client.get( + '/serviceability/states', + ifNoneMatch: first.etag, + ); + expect(second.notModified, isTrue); + expect(log.requests.last.headers['If-None-Match'], 'W/"v1"'); + }); + }); + + group('keyset pagination', () { + test('carries the cursor out of the envelope', () async { + final client = ApiClient( + httpClient: MockClient( + (_) async => http.Response( + jsonEncode({ + 'success': true, + 'data': [ + {'reference': 'DM-1'}, + ], + 'total': 42, + 'nextCursor': 'cur_2', + }), + 200, + ), + ), + sessions: MemorySessionStore(), + ); + + final page = await client.get('/bookings'); + + expect(page.rows.single['reference'], 'DM-1'); + expect(page.total, 42); + expect(page.nextCursor, 'cur_2'); + }); + }); + + group('session', () { + test('refreshes on 401, persists the rotated token, retries once', () async { + final log = _Recorder(); + final sessions = MemorySessionStore(); + await sessions.write(_session()); + + final client = ApiClient( + httpClient: _client(log, (request, _) { + if (request.url.path.endsWith('/auth/refresh')) { + return _ok(const { + 'accessToken': 'access-2', + 'refreshToken': 'refresh-2', + 'expiresIn': 3600, + }); + } + final auth = request.headers['Authorization']; + return auth == 'Bearer access-2' + ? _ok(const {'ok': true}) + : _fail(401, ApiException.unauthorized, 'Please sign in again'); + }), + sessions: sessions, + ); + + final response = await client.get('/bookings'); + + expect(response.map['ok'], isTrue); + // The rotated pair is the only one that still works, so it must be the + // one on disk before anything else uses it. + final stored = await sessions.read(); + expect(stored!.accessToken, 'access-2'); + expect(stored.refreshToken, 'refresh-2'); + }); + + test('an expired access token refreshes before the call, not after', () async { + final log = _Recorder(); + final sessions = MemorySessionStore(); + await sessions.write(_session(life: const Duration(seconds: 5))); + + final client = ApiClient( + httpClient: _client(log, (request, _) { + if (request.url.path.endsWith('/auth/refresh')) { + return _ok(const { + 'accessToken': 'access-2', + 'refreshToken': 'refresh-2', + 'expiresIn': 3600, + }); + } + return _ok(const {'ok': true}); + }), + sessions: sessions, + ); + + await client.get('/bookings'); + + // Refresh first, then the real call — and no 401 spent discovering it. + expect(log.requests.first.url.path, endsWith('/auth/refresh')); + expect(log.requests.last.headers['Authorization'], 'Bearer access-2'); + }); + + test('a dead refresh chain clears the session and reports it once', () async { + var lost = 0; + final sessions = MemorySessionStore(); + await sessions.write(_session()); + + final client = ApiClient( + httpClient: MockClient( + (_) async => _fail(401, ApiException.unauthorized, 'Please sign in again'), + ), + sessions: sessions, + )..onSessionLost = () => lost++; + + await expectLater( + client.get('/bookings'), + throwsA(isA().having((e) => e.isAuthFailure, 'auth', isTrue)), + ); + + expect(await sessions.read(), isNull); + expect(lost, 1); + }); + + test('concurrent 401s produce one refresh, not one each', () async { + final log = _Recorder(); + final sessions = MemorySessionStore(); + await sessions.write(_session()); + + final client = ApiClient( + httpClient: _client(log, (request, _) { + if (request.url.path.endsWith('/auth/refresh')) { + return _ok(const { + 'accessToken': 'access-2', + 'refreshToken': 'refresh-2', + 'expiresIn': 3600, + }); + } + return request.headers['Authorization'] == 'Bearer access-2' + ? _ok(const {'ok': true}) + : _fail(401, ApiException.unauthorized, 'Please sign in again'); + }), + sessions: sessions, + ); + + await Future.wait([ + client.get('/bookings'), + client.get('/auth/me'), + client.get('/pickup-slots'), + ]); + + final refreshes = log.requests + .where((r) => r.url.path.endsWith('/auth/refresh')) + .length; + // A rotated refresh token is single-use: a second call would replay a + // token the server has already revoked and kill the whole chain. + expect(refreshes, 1); + }); + + test('a session survives a round trip through the store', () async { + final store = MemorySessionStore(); + await store.write( + Session( + accessToken: 'a', + refreshToken: 'r', + expiresAt: DateTime.fromMillisecondsSinceEpoch(1757056800000), + customer: const Customer( + id: 'cust_1', + name: 'Joe Oommen', + phone: '+919876543210', + email: '', + ), + ), + ); + + final restored = Session.fromJson( + jsonDecode(jsonEncode((await store.read())!.toJson())) + as Map, + ); + + expect(restored!.accessToken, 'a'); + expect(restored.refreshToken, 'r'); + expect(restored.customer!.name, 'Joe Oommen'); + }); + + test('an expiring session is treated as expired a minute early', () { + expect(_session(life: const Duration(seconds: 30)).isExpired, isTrue); + expect(_session(life: const Duration(minutes: 30)).isExpired, isFalse); + }); + }); +} diff --git a/test/api_integration_test.dart b/test/api_integration_test.dart new file mode 100644 index 0000000..28fb541 --- /dev/null +++ b/test/api_integration_test.dart @@ -0,0 +1,673 @@ +import 'package:doormile_cx/data/app_config.dart'; +import 'package:doormile_cx/data/doormile_api.dart'; +import 'package:doormile_cx/data/location_service.dart'; +import 'package:doormile_cx/data/models.dart'; +import 'package:doormile_cx/state/app_state.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A programmable stand-in for the service layer. +/// +/// Not the mock API — that one invents a whole product. This one answers +/// exactly what a test sets and records exactly what it was asked, which is +/// what the wiring between [AppState] and the contract needs proving. +class FakeApi extends DoormileApi { + FakeApi({this.limits = const BookingLimits(maxPackages: 20, maxDestinations: 5)}); + + BookingLimits limits; + Object? limitsError; + + final List createKeys = []; + int createFailures = 0; + Object? createError; + + final List pages = []; + int pageCalls = 0; + final List cursors = []; + + List slots = const [ + PickupSlot( + id: 'slot_20260905_t1', + day: 'Today', + window: '2:00 – 4:00 PM', + available: true, + tag: 'Fastest pickup', + milersNearby: 4, + caption: 'Arriving in approx. 45 mins', + ), + ]; + int slotCalls = 0; + + /// Reverse-geocode answers, keyed by latitude, with a delay each — so a test + /// can make an earlier request land after a later one. + final Map geocodes = {}; + + @override + Future getBookingLimits({Place? pickup}) async { + if (limitsError != null) throw limitsError!; + return limits; + } + + @override + Future createBooking({ + required Place pickup, + required List destinations, + required String? slotId, + FareEstimate? fare, + String? idempotencyKey, + }) async { + createKeys.add(idempotencyKey); + if (createFailures > 0) { + createFailures--; + throw createError ?? ApiException(ApiException.network, 'offline'); + } + return Booking( + reference: 'DM-482913', + pickup: pickup, + destinations: destinations.map((d) => d.copy()).toList(), + slotId: slotId ?? '', + createdAt: DateTime.now(), + ); + } + + @override + Future getBookingPage({ + BookingStatus? status, + String? cursor, + int limit = 20, + }) async { + cursors.add(cursor); + if (pageCalls >= pages.length) return BookingPage.empty; + return pages[pageCalls++]; + } + + @override + Future> getPickupSlots({Place? pickup}) async { + slotCalls++; + return slots; + } + + @override + Future reverseGeocode({double? lat, double? lng}) async { + final answer = geocodes[lat]; + if (answer == null) { + return Place(title: 'Pin', sub: 'somewhere', lat: lat, lng: lng); + } + await Future.delayed(answer.$1); + return answer.$2; + } + + // ---- everything the interface needs but these tests do not exercise ------ + + @override + Future sendOtp(String identifier) async => + OtpChallenge.unspecified; + + @override + Future signUp({ + required String name, + required String phone, + String? email, + }) async => OtpChallenge.unspecified; + + @override + Future verifyOtp( + String identifier, + String code, { + String? name, + }) async => Customer( + id: 'c1', + name: name ?? 'Joe', + phone: identifier, + email: '', + ); + + @override + Future> getServiceableStates() async => const []; + + @override + Future> getServiceableDistricts(String stateCode) async => + const []; + + @override + District? districtByCode(String? code) => null; + + @override + PickupSlot? slotById(String? id) { + for (final slot in slots) { + if (slot.id == id) return slot; + } + return null; + } + + @override + Future> searchPlaces( + String query, { + double? lat, + double? lng, + }) async => const []; + + @override + Future estimateFare({ + required Place pickup, + required List destinations, + }) async => const FareEstimate(min: 49, max: 64, paymentMethod: 'UPI'); + + @override + Future cancelBooking(String reference, String? reason) async {} + + @override + @override + Future getBooking(String reference) async => null; + + @override + bool isCancellable(JourneyStage stage) => + stage.index < JourneyStage.arrived.index; + + final List<(String, JourneyStage)> stageCalls = []; + + @override + Future setStage(String reference, JourneyStage stage) async { + stageCalls.add((reference, stage)); + } +} + +Booking _booking(String reference) => Booking( + reference: reference, + pickup: const Place(title: 'Home', sub: 'Coimbatore'), + destinations: [DestinationGroup()], + slotId: 'slot_1', + createdAt: DateTime.now(), +); + +AppState _state(FakeApi api) => + AppState(api: api, location: FixedLocationService()); + +void main() { + group('nothing on screen is invented', () { + test('a fresh state holds no bookings until the server sends some', () { + // There is no seed, no demo data and no constructor side effect that + // could put a booking in front of a customer. The list starts empty and + // is only ever filled by a response. + late AppState state; + expect(() => state = _state(FakeApi()), returnsNormally); + expect(state.orders, isEmpty); + }); + + test('the orders list is the server\'s, adopted whole', () async { + final api = FakeApi() + ..pages.add(BookingPage(bookings: [_booking('DM-9')])); + final state = _state(api); + await state.refreshOrders(); + expect(state.orders.single.reference, 'DM-9'); + + // A later page that no longer contains it removes it, rather than the + // client keeping a copy the backend has forgotten. + api.pages.add(const BookingPage(bookings: [])); + await state.refreshOrders(); + expect(state.orders, isEmpty); + }); + + test('a stage move goes through the server and comes back read', () async { + final api = FakeApi() + ..pages.add(BookingPage(bookings: [_booking('DM-1')])); + final state = _state(api); + await state.refreshOrders(); + + await state.advanceStage(state.orders.single, JourneyStage.delivered); + + // The client asked; it did not apply anything to its own copy. + expect(api.stageCalls, [('DM-1', JourneyStage.delivered)]); + expect(state.orders.single.stage, JourneyStage.booked); + expect(state.orders.single.destinations.single.trackingId, isNull); + }); + }); + + group('development access', () { + test('no dev token is compiled into this build', () { + // A pasted token is not present unless a define supplies one. + expect(AppConfig.hasDevToken, isFalse); + expect(AppConfig.describe, isNot(contains('DEV TOKEN'))); + }); + + test('the test environment is a bypass, and says so out loud', () { + // The Miler app shipped a flag that swapped authentication for a fake + // rider on any four digits, and said nothing on screen. The rule that + // replaces it: a build that did not sign in must name it. Under + // FLUTTER_TEST the dev data is on, so it must read as bypassed and the + // environment line must carry the DEV DATA marker. + expect(AppConfig.useDevData, isTrue, reason: 'dev data is on under test'); + expect(AppConfig.authBypassed, isTrue); + expect(AppConfig.describe, contains('DEV DATA')); + }); + + test('the auto-login default never reaches the tests', () { + // Dev data now defaults on in a debug build, and with it the login + // screen is skipped — one `flutter run` lands on Home, because staging + // has no SMS gateway to send a code through. + // + // The widget tests must keep walking that entrance anyway: they cover the + // phone field, the code screen, and the guard that refuses to open the + // code screen when nothing was sent. A session waiting for them at launch + // would delete all of it and nothing would fail, so [devAutoLogin] is + // switched off under FLUTTER_TEST on purpose. This is that promise. + expect(AppConfig.devAutoLogin, isFalse); + }); + + // The guarantee that matters cannot be asserted from a debug test — every + // switch is `!kReleaseMode`, so `useDevData`, `devAutoLogin`, `hasDevToken` + // and `allowStageOverride` are all forced false in a release build, + // whatever the defines say. That is what keeps a bypass out of the store. + }); + + group('booking limits', () { + test('a failed limits call falls back to one destination, not five', () async { + final api = FakeApi()..limitsError = ApiException('network', 'offline'); + final state = _state(api); + await Future.delayed(Duration.zero); + + // Multi-destination is undeliverable until the Miler build lands. The + // permissive default is the one that strands a parcel. + expect(state.limits.maxDestinations, 1); + expect(state.allowsMultipleDestinations, isFalse); + expect(state.addDestination(), isFalse); + }); + + test('the served cap is authoritative when it arrives', () async { + final state = _state(FakeApi()); + await state.loadLimits(); + + expect(state.limits.maxDestinations, 5); + expect(state.allowsMultipleDestinations, isTrue); + }); + + test('a zero or missing cap resolves to one, never to none', () { + expect(BookingLimits.fromJson(const {}).maxDestinations, 1); + expect(BookingLimits.fromJson(const {}).maxPackages, 20); + expect( + BookingLimits.fromJson(const {'maxDestinations': 0, 'maxPackages': 0}) + .maxDestinations, + 1, + ); + expect( + BookingLimits.fromJson(const {'maxPackages': 0}).maxPackages, + 1, + ); + }); + }); + + group('pickup slots', () { + test('starting a booking drops yesterday\'s slots', () async { + final api = FakeApi(); + final state = _state(api); + + await state.loadSlots(); + await state.loadSlots(); + expect(api.slotCalls, 1, reason: 'cached within one booking'); + + final epoch = state.slotsEpoch; + state.startBooking(); + await state.loadSlots(); + + expect(api.slotCalls, 2); + expect(state.slotsEpoch, greaterThan(epoch)); + }); + + test('a refused slot clears the cache and the choice', () async { + final api = FakeApi() + ..createFailures = 1 + ..createError = ApiException( + ApiException.invalid, + 'That pickup time has passed — pick a new slot', + ); + final state = _state(api); + state.startBooking(); + state.pickup = const Place(title: 'Home', sub: 'CBE', lat: 11, lng: 76); + state.draftPickup = state.pickup; + await state.loadSlots(); + state.selectSlot(api.slots.first); + + await expectLater(state.confirmBooking(), throwsA(isA())); + + expect(state.draftSlotId, isNull); + expect(state.slotsCache, isNull); + }); + + test('the parser keeps every field the picker renders', () { + final slot = PickupSlot.fromJson(const { + 'id': 'slot_20260905_t1', + 'day': 'Today', + 'window': '2:00 – 4:00 PM', + 'available': true, + 'tag': 'Fastest pickup', + 'milersNearby': 4, + 'caption': 'Arriving in approx. 45 mins', + 'note': null, + }); + + expect(slot.tag, 'Fastest pickup'); + expect(slot.milersNearby, 4); + expect(slot.caption, 'Arriving in approx. 45 mins'); + expect(slot.label, 'Today · 2:00 – 4:00 PM'); + }); + }); + + group('idempotency', () { + test('a retried booking reuses its key; a new booking mints one', () async { + final api = FakeApi()..createFailures = 1; + final state = _state(api); + state.startBooking(); + state.pickup = const Place(title: 'Home', sub: 'CBE', lat: 11, lng: 76); + state.draftPickup = state.pickup; + state.selectSlot(api.slots.first); + + await expectLater(state.confirmBooking(), throwsA(isA())); + await state.confirmBooking(); + + expect(api.createKeys.length, 2); + expect(api.createKeys[0], isNotNull); + // Same intent, same key — the server replays rather than booking twice. + expect(api.createKeys[0], api.createKeys[1]); + + state.startBooking(); + state.draftPickup = state.pickup; + state.selectSlot(api.slots.first); + await state.confirmBooking(); + + expect(api.createKeys[2], isNot(api.createKeys[1])); + }); + }); + + group('keyset pagination', () { + test('appends pages and follows the cursor', () async { + final api = FakeApi() + ..pages.addAll([ + BookingPage(bookings: [_booking('DM-1'), _booking('DM-2')], nextCursor: 'c2'), + BookingPage(bookings: [_booking('DM-3')], nextCursor: null), + ]); + final state = _state(api); + + await state.refreshOrders(); + expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2']); + expect(state.hasMoreOrders, isTrue); + + await state.loadMoreOrders(); + expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2', 'DM-3']); + expect(state.hasMoreOrders, isFalse); + expect(api.cursors, [null, 'c2']); + }); + + test('a row that arrives on two pages is shown once', () async { + final api = FakeApi() + ..pages.addAll([ + BookingPage(bookings: [_booking('DM-1')], nextCursor: 'c2'), + BookingPage(bookings: [_booking('DM-1'), _booking('DM-2')]), + ]); + final state = _state(api); + + await state.refreshOrders(); + await state.loadMoreOrders(); + + expect(state.orders.map((o) => o.reference), ['DM-1', 'DM-2']); + }); + + test('a failed page keeps the cursor so the next scroll retries it', () async { + final api = FakeApi() + ..pages.add(BookingPage(bookings: [_booking('DM-1')], nextCursor: 'c2')); + final state = _state(api); + await state.refreshOrders(); + + // The fake runs out of pages and returns an empty one; the cursor then + // clears normally. What matters is that nothing throws at the UI. + await state.loadMoreOrders(); + expect(state.loadingMoreOrders, isFalse); + }); + }); + + group('pickup point', () { + test('the latest reverse-geocode wins, however the earlier one lands', () async { + final api = FakeApi() + ..geocodes[11.0] = ( + const Duration(milliseconds: 80), + const Place(title: 'First drag', sub: 'stale', lat: 11.0, lng: 76.0), + ) + ..geocodes[12.0] = ( + const Duration(milliseconds: 10), + const Place(title: 'Second drag', sub: 'fresh', lat: 12.0, lng: 77.0), + ); + final state = _state(api); + + final first = state.setPickupFromPin(11.0, 76.0); + final second = state.setPickupFromPin(12.0, 77.0); + await Future.wait([first, second]); + + expect(state.pickup!.title, 'Second drag'); + expect(state.resolvingPickup, isFalse); + }); + + test('a refused permission still leaves a usable pickup', () async { + final state = AppState( + api: FakeApi(), + location: FixedLocationService.denied(LocationDenial.deniedForever), + ); + + await state.detectPickupLocation(); + expect(state.locationDenial, LocationDenial.deniedForever); + // Falls back to the service-area centre rather than stranding the form, + // and search still overrides it. + expect(state.pickup, isNotNull); + + state.setPickup( + const Place(title: 'Brookefields Mall', sub: 'CBE', lat: 10.99, lng: 76.96), + ); + expect(state.pickup!.title, 'Brookefields Mall'); + expect(state.hasPickupPoint, isTrue); + expect(state.locationDenial, isNull); + }); + }); + + group('booking parser', () { + final json = { + 'reference': 'DM-482913', + 'stage': 'in_transit', + 'status': 'active', + 'cancellable': false, + 'createdAt': 1757056800000, + 'pickup': { + 'title': '12 Nehru Street', + 'sub': 'Gandhipuram, Coimbatore 641012', + 'lat': 11.0183, + 'lng': 76.9725, + }, + 'slotId': 'slot_t_1', + 'destinations': [ + { + 'stateCode': 'TN', + 'stateName': 'Tamil Nadu', + 'districtCode': 'TN-MAA', + 'districtName': 'Chennai', + 'packageCount': 2, + 'district': { + 'code': 'TN-MAA', + 'name': 'Chennai', + 'available': true, + 'hub': 'Chennai Guindy Hub', + 'promise': 'Next-day delivery', + 'lat': 13.08, + 'lng': 80.27, + }, + 'details': { + 'street': '12th Main', + 'recipientName': 'Meera S', + 'recipientPhone': '+919884412210', + }, + 'trackingId': 'DMX10482913', + 'stage': 'in_transit', + 'verification': { + 'weightKg': 2.8, + 'photos': ['https://cdn.doormile.com/pv/abc.jpg'], + 'capturedAt': 1757060400000, + 'capturedBy': 'Arun Kumar', + }, + }, + ], + 'miler': { + 'name': 'Arun Kumar', + 'vehicle': 'TN 37 BX 4412', + 'phone': '+919000011223', + 'rating': 4.9, + 'trips': 1240, + 'vehicleType': 'E-Scooter', + }, + 'deliveryAgent': null, + 'milerDistanceKm': null, + 'milerEtaMinutes': null, + 'milersInZone': 3, + 'routeKm': 6.4, + 'expectedDelivery': 'Thu, 12 Sep', + 'fare': { + 'min': 49, + 'max': 64, + 'paymentMethod': 'UPI · Cash at doorstep', + 'parcel': 'Standard box (up to 3 kg)', + }, + 'amountPaid': 64, + 'deliveredAt': null, + 'cancelReason': null, + 'history': [ + {'stage': 'booked', 'at': 1757056800000}, + {'stage': 'assigned', 'at': 1757057400000}, + {'stage': 'in_transit', 'at': 1757062200000}, + ], + }; + + test('reads the whole canonical object, not a subset', () { + final booking = Booking.fromJson(json); + + expect(booking.reference, 'DM-482913'); + expect(booking.stage, JourneyStage.inTransit); + expect(booking.status, BookingStatus.active); + expect(booking.cancellable, isFalse); + expect(booking.createdAt.millisecondsSinceEpoch, 1757056800000); + expect(booking.pickup.title, '12 Nehru Street'); + expect(booking.slotId, 'slot_t_1'); + expect(booking.routeKm, 6.4); + expect(booking.milersInZone, 3); + expect(booking.expectedDelivery, 'Thu, 12 Sep'); + expect(booking.amountPaid, 64); + expect(booking.deliveredAt, isNull); + expect(booking.miler!.name, 'Arun Kumar'); + expect(booking.miler!.trips, 1240); + expect(booking.deliveryAgent, isNull); + expect(booking.fare!.range, '₹49 – ₹64'); + expect(booking.history.length, 3); + expect(booking.history.first.stage, JourneyStage.booked); + expect(booking.timeOf(JourneyStage.assigned), isNotNull); + + final group = booking.destinations.single; + expect(group.destination.label, 'Chennai, Tamil Nadu'); + expect(group.packageCount, 2); + expect(group.trackingId, 'DMX10482913'); + expect(group.stage, JourneyStage.inTransit); + expect(group.details.recipientName, 'Meera S'); + expect(group.district!.hub, 'Chennai Guindy Hub'); + expect(group.hub, isNotNull, reason: 'district coordinates draw the route'); + expect(group.verification!.weightLabel, '2.8 kg'); + expect(group.verification!.photos.single, endsWith('abc.jpg')); + }); + + test('before order_created there is no order to track', () { + final pending = Map.from(json) + ..['stage'] = 'assigned' + ..['amountPaid'] = null + ..['destinations'] = [ + { + 'stateCode': 'TN', + 'stateName': 'Tamil Nadu', + 'districtCode': 'TN-MAA', + 'districtName': 'Chennai', + 'packageCount': 1, + }, + ]; + + final booking = Booking.fromJson(pending); + final group = booking.destinations.single; + + expect(group.trackingId, isNull); + expect(group.stage, isNull); + expect(group.verification, isNull); + expect(booking.amountPaid, isNull); + expect(booking.hasOrders, isFalse); + }); + + test('an unknown stage key falls back to booked', () { + final unknown = Map.from(json) + ..['stage'] = 'delivery_failed'; + expect(Booking.fromJson(unknown).stage, JourneyStage.booked); + expect(JourneyStage.fromKey(null), JourneyStage.booked); + expect(JourneyStage.fromKey('delivered'), JourneyStage.delivered); + }); + + test('a cancelled booking parses even when the server thins it out', () { + final booking = Booking.fromJson(const { + 'reference': 'DM-449220', + 'status': 'cancelled', + 'stage': 'booked', + 'cancelReason': 'Package not ready', + }); + + expect(booking.status, BookingStatus.cancelled); + expect(booking.isCancelled, isTrue); + expect(booking.cancelReason, 'Package not ready'); + expect(booking.destinations, hasLength(1)); + expect(booking.slotId, ''); + }); + + test('timestamps are epoch millis, and nothing ambiguous is guessed', () { + expect( + epochMillis(1757056800000)!.millisecondsSinceEpoch, + 1757056800000, + ); + expect(epochMillis('1757056800000')!.millisecondsSinceEpoch, 1757056800000); + expect(epochMillis('2026-09-05T14:30:00+05:30'), isNotNull); + expect(epochMillis(null), isNull); + // A naive local string is ambiguous by 5h30m here. Refusing it is how the + // tracking screen avoids dating a parcel to yesterday. + expect(epochMillis('2026-09-05 14:30:00'), isNull); + }); + + test('a destination round-trips into the create request', () { + final group = DestinationGroup( + destination: Destination(stateCode: 'TN', districtCode: 'TN-MAA'), + packageCount: 3, + ); + expect(group.toBookingJson(), { + 'stateCode': 'TN', + 'districtCode': 'TN-MAA', + 'packageCount': 3, + }); + + // The contract carries the recipient flat on the destination, beside the + // codes, and spells a dropped pin `latitude`/`longitude`. + group.details.update(recipientName: 'Meera S', building: '12/A'); + group.details.pin = const MapPin(13.085, 80.21); + expect(group.toBookingJson(), { + 'stateCode': 'TN', + 'districtCode': 'TN-MAA', + 'packageCount': 3, + 'recipientName': 'Meera S', + 'building': '12/A', + 'latitude': 13.085, + 'longitude': 80.21, + }); + }); + + test('a PATCH sends nulls, because null is how a field is cleared', () { + final details = DeliveryDetails(street: '12th Main'); + final patch = details.toPatchJson(); + expect(patch['street'], '12th Main'); + expect(patch.containsKey('landmark'), isTrue); + expect(patch['landmark'], isNull); + expect(patch.containsKey('latitude'), isTrue); + }); + }); +} diff --git a/test/booking_flow_test.dart b/test/booking_flow_test.dart new file mode 100644 index 0000000..ea4756c --- /dev/null +++ b/test/booking_flow_test.dart @@ -0,0 +1,799 @@ +import 'dart:async'; + +import 'package:doormile_cx/data/doormile_api.dart'; +import 'package:doormile_cx/data/location_service.dart'; +import 'package:doormile_cx/data/models.dart'; +import 'package:doormile_cx/main.dart'; +import 'package:doormile_cx/state/app_state.dart'; +import 'package:doormile_cx/ui/widgets/buttons.dart'; +import 'package:doormile_cx/ui/widgets/map_panel.dart'; +import 'package:doormile_cx/ui/widgets/chrome.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:doormile_cx/data/dev_doormile_api.dart'; + +/// The app runs continuous animations (radar, live dots, map pulse, skeletons), +/// so `pumpAndSettle` would never return. Pump fixed slices instead. +Future settle(WidgetTester tester, [int ms = 800]) async { + await tester.pump(); + await tester.pump(Duration(milliseconds: ms)); + await tester.pump(const Duration(milliseconds: 400)); +} + +/// Drains toast and fare-estimate timers so a test ends with nothing pending. +Future drainToasts(WidgetTester tester) async { + await tester.pump(const Duration(seconds: 3)); +} + + +/// The street line currently shown under the pickup map. Empty while the pin +/// is still being resolved, which is itself the thing worth asserting. +String pickupAddress(WidgetTester tester) { + final title = find.byKey(const ValueKey('pickupAddressTitle')); + return title.evaluate().isEmpty + ? '' + : tester.widget(title).data ?? ''; +} + +Future signIn(WidgetTester tester, {AppState? state}) async { + // A tall surface so whole screens fit without scrolling in the harness. + tester.view.physicalSize = const Size(1230, 9000); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(DoormileApp(initialState: state)); + await settle(tester); + + await tester.enterText(find.byType(TextField).first, '9876543210'); + // The CTA only enables once the field rebuilds with a valid number. + await settle(tester, 200); + await tester.tap(find.text('Continue')); + await settle(tester); + + // The OTP screen verifies as soon as the fourth digit lands. + final boxes = find.byType(TextField); + for (var i = 0; i < 4; i++) { + await tester.enterText(boxes.at(i), '1'); + await tester.pump(); + } + await settle(tester); +} + +/// Moves a booking on the **server**, then lets the app read it back. +/// +/// This is the only way a stage changes now: the app has no local stage +/// machine, so a test cannot step a booking without the backend doing it +/// first. Fire-and-pump rather than await — the fake answers on a delay the +/// test clock only advances while pumping. +Future serverAdvance( + WidgetTester tester, + AppState state, + String reference, + JourneyStage stage, +) async { + final booking = state.byReference(reference)!; + unawaited(state.advanceStage(booking, stage)); + await settle(tester, 1400); +} + +/// Home ▸ BOOK ▸ confirm the pin ▸ destination step. +Future openDestination(WidgetTester tester) async { + await tester.tap(find.text('Start booking')); + await settle(tester); + await tester.tap(find.text('Next')); + await settle(tester); +} + +/// State chips scroll horizontally; drag later ones fully into view first. +Future tapState(WidgetTester tester, String name) async { + final width = tester.view.physicalSize.width / tester.view.devicePixelRatio; + final chips = find + .ancestor(of: find.text('Tamil Nadu'), matching: find.byType(Scrollable)) + .first; + + for (var i = 0; i < 8; i++) { + final finder = find.text(name); + if (finder.evaluate().isNotEmpty) { + final rect = tester.getRect(finder); + if (rect.left >= 0 && rect.right <= width) break; + } + await tester.drag(chips, const Offset(-160, 0)); + await tester.pump(); + } + await tester.tap(find.text(name)); + await settle(tester); +} + +Future pickPlace(WidgetTester tester, String state, String district) async { + await tapState(tester, state); + await tester.tap(find.text(district)); + await settle(tester); +} + +/// The fake standing in for the backend in this file. +/// +/// A fresh one per test, installed as the singleton so the widgets under test +/// reach it exactly as they would reach the real API. It is the **only** source +/// of bookings here: nothing plants one in [AppState]. +late DevDoormileApi api; + +void main() { + setUp(() { + api = DevDoormileApi(); + DoormileApi.overrideInstance(api); + + // Widget tests run on a fake clock a real platform-channel reply never + // reaches, so the device GPS is replaced with a fixed fix. Without this the + // pickup screen would sit on "Getting address…" forever. + LocationService.instance = FixedLocationService(); + }); + + tearDown(() => DoormileApi.overrideInstance(null)); + + testWidgets('signs in and lands on Home', (tester) async { + await signIn(tester); + + expect(find.text('Send a parcel'), findsOneWidget); + expect(find.text('Start booking'), findsOneWidget); + // The seeded active booking surfaces on Home. + expect(find.text('Miler assigned'), findsOneWidget); + await drainToasts(tester); + }); + + testWidgets('dragging the map re-resolves the pickup address', + (tester) async { + await signIn(tester); + await tester.tap(find.text('Start booking')); + await settle(tester); + + // The pin resolved to a street, not a placeholder. + expect(find.text('Getting address…'), findsNothing); + final address = pickupAddress(tester); + expect(address, isNotEmpty); + + // Move the map under the fixed pin, the way a customer corrects the door. + await tester.drag(find.byType(DmMapPanel), const Offset(-90, -70)); + await tester.pump(); + + // The address goes quiet while the new coordinates are resolved… + expect(find.text('Getting address…'), findsOneWidget); + + // …and comes back as a different place. + await settle(tester, 900); + expect(find.text('Getting address…'), findsNothing); + expect(pickupAddress(tester), isNot(address)); + + // Which is what Next now carries forward. + await tester.tap(find.text('Next')); + await settle(tester); + expect(find.text('Where are you sending it?'), findsOneWidget); + await drainToasts(tester); + }); + + testWidgets('GPS, map drag and search all write one pickup point', + (tester) async { + final state = AppState( + location: FixedLocationService(lat: 11.0183, lng: 76.9725), + ); + await signIn(tester, state: state); + await tester.tap(find.text('Start booking')); + await settle(tester); + + DmMapPanel map() => tester.widget(find.byType(DmMapPanel)); + + // 1 — the device fix. Address and coordinates land together. + final located = state.pickup!; + expect(state.hasPickupPoint, isTrue); + expect(located.lat, closeTo(11.0183, 0.0001)); + expect(map().lat, located.lat); + expect(pickupAddress(tester), located.title); + + // 2 — dragging the map moves the same point. + await tester.drag(find.byType(DmMapPanel), const Offset(-140, -110)); + await settle(tester, 900); + final dragged = state.pickup!; + expect(dragged.lat, isNot(located.lat)); + expect(dragged.hasLocation, isTrue); + expect(map().lat, dragged.lat); + expect(pickupAddress(tester), dragged.title); + + // 3 — a search result overwrites it, coordinates included, and the map + // follows the address rather than staying where the drag left it. + await tester.tap(find.text('Change')); + await settle(tester); + await tester.tap(find.text('Brookefields Mall')); + await settle(tester); + + final searched = state.pickup!; + expect(searched.title, 'Brookefields Mall'); + expect(searched.lat, closeTo(10.9987, 0.0001)); + expect(searched.lng, closeTo(76.9628, 0.0001)); + expect(map().lat, searched.lat); + expect(map().lng, searched.lng); + expect(pickupAddress(tester), 'Brookefields Mall'); + + // …and that one point is what the booking carries forward. + await tester.tap(find.text('Next')); + await settle(tester); + expect(find.text('Where are you sending it?'), findsOneWidget); + expect(state.draftPickup!.lat, searched.lat); + expect(state.draftPickup!.lng, searched.lng); + expect(state.draftPickup!.title, 'Brookefields Mall'); + await drainToasts(tester); + }); + + testWidgets('the customer is never shown raw coordinates', (tester) async { + final state = AppState( + location: FixedLocationService(lat: 11.0183, lng: 76.9725), + ); + await signIn(tester, state: state); + await tester.tap(find.text('Start booking')); + await settle(tester); + + // Whatever the pin resolves to, it reads as a place — never a decimal pair. + final coordinate = RegExp(r'-?\d{1,3}\.\d{3,}'); + for (final text in tester.widgetList(find.byType(Text))) { + final data = text.data; + if (data == null) continue; + expect( + coordinate.hasMatch(data), + isFalse, + reason: 'coordinates leaked into the UI: "$data"', + ); + } + expect(state.pickup!.hasLocation, isTrue); + await drainToasts(tester); + }); + + testWidgets('the customer sees their own position as a blue dot', + (tester) async { + final state = AppState( + location: FixedLocationService( + lat: 11.0183, + lng: 76.9725, + accuracy: 22, + ), + ); + await signIn(tester, state: state); + await tester.tap(find.text('Start booking')); + await settle(tester); + + final dot = find.byKey(const ValueKey('deviceLocationDot')); + expect(dot, findsOneWidget); + expect(state.hasDeviceLocation, isTrue); + expect(state.deviceLat, closeTo(11.0183, 0.0001)); + expect(state.deviceAccuracy, 22); + + // Dragging moves the pickup pin. It must not move the customer: the dot + // stays where they actually are, which is the whole point of showing it. + await tester.drag(find.byType(DmMapPanel), const Offset(-150, -120)); + await settle(tester, 900); + + expect(dot, findsOneWidget); + expect(state.deviceLat, closeTo(11.0183, 0.0001)); + expect(state.deviceLng, closeTo(76.9725, 0.0001)); + expect(state.pickup!.lat, isNot(closeTo(11.0183, 0.0001))); + await drainToasts(tester); + }); + + testWidgets('no blue dot when the device refused a position', + (tester) async { + LocationService.instance = + FixedLocationService.denied(LocationDenial.serviceOff); + + final state = AppState(); + await signIn(tester, state: state); + await tester.tap(find.text('Start booking')); + await settle(tester); + + // A dot in the wrong place is worse than no dot. + expect(find.byKey(const ValueKey('deviceLocationDot')), findsNothing); + expect(state.hasDeviceLocation, isFalse); + await drainToasts(tester); + }); + + testWidgets('a refused location still lets the customer book', + (tester) async { + LocationService.instance = + FixedLocationService.denied(LocationDenial.denied); + + await signIn(tester); + await tester.tap(find.text('Start booking')); + await settle(tester); + + // The map falls back to the service area rather than an empty ocean, and + // the refusal is explained instead of silently swallowed. + expect( + find.textContaining('Allow location access'), + findsOneWidget, + ); + expect(find.text('Search'), findsOneWidget); + + // Booking is not blocked by it. + expect( + tester.widget(find.widgetWithText(DmButton, 'Next')).onPressed, + isNotNull, + ); + await drainToasts(tester); + }); + + testWidgets('one destination, one package, every optional field skipped', + (tester) async { + final state = AppState(); + await signIn(tester, state: state); + + await openDestination(tester); + expect(find.text('Where are you sending it?'), findsOneWidget); + + await pickPlace(tester, 'Tamil Nadu', 'Chennai'); + + // Settles into one card carrying the place and nothing else demanded. + // + // The package stepper and the optional delivery-details row were removed: + // one package to one door is the whole of the common path, so the step now + // asks for the destination and stops. `packageCount` still defaults to 1 in + // the model, which the read-only screens go on showing. + expect(find.text('Chennai, Tamil Nadu'), findsOneWidget); + expect(find.text('How many packages?'), findsNothing); + expect(find.text('Add exact delivery details'), findsNothing); + expect(find.text('Send to another place'), findsOneWidget); + + await tester.tap(find.text('Next')); + await settle(tester); + expect(find.text('When should we come?'), findsOneWidget); + + await tester.tap(find.text('2:00 – 4:00 PM').first); + await tester.pump(); + await tester.tap(find.text('Review booking')); + await settle(tester); + + expect(find.text('Review pickup details'), findsOneWidget); + expect(find.text('SENDING TO'), findsOneWidget); + expect(find.text('1 package'), findsOneWidget); + // Optional omissions are stated calmly, never as an error. + expect( + find.text('Not added — the Miler can confirm this at pickup.'), + findsOneWidget, + ); + // A single destination shows no multi-destination summary. + expect(find.textContaining('destinations'), findsNothing); + + await tester.tap(find.text('Book pickup')); + await settle(tester); + + expect(find.text('Pickup booked'), findsOneWidget); + final booking = state.trackedBooking!; + expect(booking.destinations, hasLength(1)); + expect(booking.totalPackages, 1); + expect(booking.destinations.first.trackingId, isNull); + await drainToasts(tester); + }); + + // ── These three used to drive the package stepper ── + // + // The stepper is gone from the destination step: one package to one door is + // the whole common path, so the screen no longer asks. The *logic* it drove + // is untouched — `setPackageCount` still clamps to `limits.maxPackages`, the + // totals still roll up, and the booking payload still carries them — so the + // counts are set on the state here and the assertions that matter (totals, + // the cap, what review and the booking show) are kept exactly as they were. + testWidgets('one destination, four packages', (tester) async { + final state = AppState(); + await signIn(tester, state: state); + + await openDestination(tester); + await pickPlace(tester, 'Tamil Nadu', 'Coimbatore'); + + state.setPackageCount(0, 4); + await tester.pump(); + expect(state.draftTotalPackages, 4); + + await tester.tap(find.text('Next')); + await settle(tester); + await tester.tap(find.text('2:00 – 4:00 PM').first); + await tester.pump(); + await tester.tap(find.text('Review booking')); + await settle(tester); + + expect(find.text('4 packages'), findsOneWidget); + await drainToasts(tester); + }); + + testWidgets('three destinations with mixed counts total correctly on review', + (tester) async { + final state = AppState(); + await signIn(tester, state: state); + + await openDestination(tester); + await pickPlace(tester, 'Tamil Nadu', 'Chennai'); + state.setPackageCount(0, 2); + await tester.pump(); + + await tester.tap(find.text('Send to another place')); + await settle(tester); + await pickPlace(tester, 'Kerala', 'Ernakulam'); + + await tester.tap(find.text('Send to another place')); + await settle(tester); + await pickPlace(tester, 'Karnataka', 'Bengaluru Urban'); + state.setPackageCount(2, 3); + await tester.pump(); + + expect(state.draftDestinations, hasLength(3)); + expect(state.draftTotalPackages, 6); + + await tester.tap(find.text('Next')); + await settle(tester); + await tester.tap(find.text('2:00 – 4:00 PM').first); + await tester.pump(); + await tester.tap(find.text('Review booking')); + await settle(tester); + + expect(find.text('6 packages · 3 destinations'), findsOneWidget); + expect(find.text('Chennai, Tamil Nadu'), findsOneWidget); + expect(find.text('Ernakulam, Kerala'), findsOneWidget); + expect(find.text('Bengaluru Urban, Karnataka'), findsOneWidget); + + await tester.tap(find.text('Book pickup')); + await settle(tester); + final booking = state.trackedBooking!; + expect(booking.destinations, hasLength(3)); + expect(booking.totalPackages, 6); + expect(booking.packageSummary, '6 packages · 3 destinations'); + // Before collection it is one pickup, however many places it serves. + expect( + state + .entriesOf(BookingStatus.active) + .where((e) => e.booking.reference == booking.reference) + .length, + 1, + ); + await drainToasts(tester); + }); + + testWidgets('the package cap holds', (tester) async { + final state = AppState(); + await signIn(tester, state: state); + + await openDestination(tester); + await pickPlace(tester, 'Tamil Nadu', 'Chennai'); + + // Push well past the cap; `setPackageCount` must clamp. + state.setPackageCount(0, 999); + await tester.pump(); + + expect(state.draftTotalPackages, state.limits.maxPackages); + // A full pickup cannot take another place either. + expect(state.canAddDestination, isFalse); + expect( + find.text('Up to ${state.limits.maxPackages} packages in one pickup.'), + findsOneWidget, + ); + await drainToasts(tester); + }); + + testWidgets('the destination cap holds', (tester) async { + final state = AppState(); + await signIn(tester, state: state); + + await openDestination(tester); + + const places = [ + ('Tamil Nadu', 'Chennai'), + ('Kerala', 'Ernakulam'), + ('Karnataka', 'Mysuru'), + ('Tamil Nadu', 'Erode'), + ('Kerala', 'Thrissur'), + ]; + + for (var i = 0; i < places.length; i++) { + if (i > 0) { + await tester.tap(find.text('Send to another place')); + await settle(tester); + } + await pickPlace(tester, places[i].$1, places[i].$2); + } + + expect(state.draftDestinations, hasLength(state.limits.maxDestinations)); + expect(state.canAddDestination, isFalse); + expect(find.text('Send to another place'), findsNothing); + expect( + find.text( + 'Up to ${state.limits.maxDestinations} places in one pickup. ' + 'Book another pickup for more.', + ), + findsOneWidget, + ); + await drainToasts(tester); + }); + + testWidgets('the picker offers serviceable districts only', (tester) async { + await signIn(tester); + await openDestination(tester); + + // Nothing until a state is picked. + expect(find.text('Chennai'), findsNothing); + + await tapState(tester, 'Tamil Nadu'); + expect(find.text('Chennai'), findsOneWidget); + expect(find.text('Coimbatore'), findsOneWidget); + expect(find.text('Ernakulam'), findsNothing); + + // Unsupported places are never disabled tiles — they are named once, + // quietly, and only because they are genuinely coming. + expect(find.text('Madurai'), findsNothing); + expect(find.text('Coming soon: Madurai'), findsOneWidget); + + await tapState(tester, 'Kerala'); + expect(find.text('Ernakulam'), findsOneWidget); + expect(find.text('Palakkad'), findsOneWidget); + expect(find.text('Chennai'), findsNothing); + expect(find.text('Coming soon: Kozhikode'), findsOneWidget); + await drainToasts(tester); + }); + + testWidgets('a state with nothing open is not offered at all', (tester) async { + await signIn(tester); + await openDestination(tester); + + // Puducherry is in the backend response but has no open district, so the + // customer never has to read past it. + expect(find.text('Puducherry'), findsNothing); + expect(find.text('Tamil Nadu'), findsOneWidget); + await drainToasts(tester); + }); + + testWidgets('orders split into their own rows once collected', (tester) async { + final state = AppState(); + await signIn(tester, state: state); + + await tester.tap(find.text('Orders')); + await settle(tester); + + // Before collection: one row, owned by the pickup. + expect(find.text('DM-482913'), findsOneWidget); + + // After collection: the seeded multi-destination pickup is in transit, so + // each destination stands on its own with a quiet line back to the visit. + final booking = state.byReference('DM-471200')!; + expect(booking.hasOrders, isTrue); + expect(find.text('Pickup DM-471200'), findsNothing); + expect(find.text('Ernakulam, Kerala'), findsOneWidget); + expect(find.text('Coimbatore, Tamil Nadu'), findsOneWidget); + expect( + find.text('Collected on DM-471200'), + findsNWidgets(booking.destinations.length), + ); + // Independently tracked. + expect( + booking.destinations.map((d) => d.trackingId).toSet().length, + booking.destinations.length, + ); + + // Opening one follows that order, not the whole visit. + await tester.tap(find.text('Ernakulam, Kerala')); + await settle(tester); + expect(find.text('To Ernakulam, Kerala'), findsOneWidget); + expect(find.text('YOUR PACKAGES'), findsOneWidget); + await drainToasts(tester); + }); + + testWidgets('the customer timeline groups the operational states', + (tester) async { + final state = AppState(); + await signIn(tester, state: state); + + await tester.tap(find.text('Miler assigned')); + await settle(tester); + + // Tracking opens on the three most recent updates; the rest of the journey + // is one tap away. + await tester.tap(find.text('All 7 stages')); + await settle(tester); + + // Seven customer milestones, not nine backend states. + expect(find.text('Pickup booked'), findsWidgets); + expect(find.text('Pickup in progress'), findsOneWidget); + expect(find.text('Package collected'), findsOneWidget); + // Operational states are never timeline rows of their own. + expect(find.text('Miler arrived'), findsNothing); + expect(find.text('Order created'), findsNothing); + + // Advancing to "on the way" keeps the milestone and moves the detail. It + // reads twice: once on the timeline, and once as the caption on the live + // card's own progress rail. + await serverAdvance(tester, state, 'DM-482913', JourneyStage.onTheWay); + expect(find.text('Pickup in progress'), findsNWidgets(2)); + expect(find.textContaining('Miler on the way'), findsWidgets); + // The pill speaks customer language, not the operational stage. + expect(find.text('Order created'), findsNothing); + await drainToasts(tester); + }); + + testWidgets('packages stay together until the pickup is actually collected', + (tester) async { + final state = AppState(); + await signIn(tester, state: state); + + await tester.tap(find.text('Miler assigned')); + await settle(tester); + + // The live card, not an eyebrow: a pickup in flight is the screen's one + // crimson surface. + expect(find.text('LIVE'), findsOneWidget); + expect(find.text('Cancel this pickup'), findsOneWidget); + // No per-package breakdown before collection. + expect(find.text('YOUR PACKAGES'), findsNothing); + + await serverAdvance(tester, state, 'DM-482913', JourneyStage.pickedUp); + // Custody has changed hands: cancellation is no longer offered. + expect(find.text('Cancel this pickup'), findsNothing); + + await serverAdvance(tester, state, 'DM-482913', JourneyStage.orderCreated); + final booking = state.byReference('DM-482913')!; + expect(booking.stage, JourneyStage.orderCreated); + expect(booking.destinations.first.trackingId, isNotNull); + await drainToasts(tester); + }); + + testWidgets('cancels the whole pickup from tracking', (tester) async { + final state = AppState(); + await signIn(tester, state: state); + + await tester.tap(find.text('Miler assigned')); + await settle(tester); + + await tester.tap(find.text('Cancel this pickup')); + await settle(tester); + expect(find.text('Cancel this pickup?'), findsOneWidget); + + await tester.tap(find.text('Package not ready')); + await tester.pump(); + await tester.tap(find.text('Yes, cancel pickup')); + await settle(tester); + + expect(find.text('Booking cancelled'), findsOneWidget); + final booking = state.byReference('DM-482913')!; + expect(booking.status, BookingStatus.cancelled); + await drainToasts(tester); + }); + + testWidgets('shows the empty service-area state', (tester) async { + await signIn(tester); + api.flags.emptyServiceAreas = true; + + await openDestination(tester); + + expect(find.text('No service areas available'), findsOneWidget); + await drainToasts(tester); + }); + + testWidgets('shows the network error state with retry', (tester) async { + await signIn(tester); + api.flags.networkError = true; + + await openDestination(tester); + + expect(find.text("Couldn't load this"), findsOneWidget); + + api.flags.networkError = false; + await tester.tap(find.text('Retry')); + await settle(tester); + expect(find.text('Tamil Nadu'), findsOneWidget); + await drainToasts(tester); + }); + + testWidgets('a delivered order opens its receipt', (tester) async { + await signIn(tester); + + await tester.tap(find.text('Orders')); + await settle(tester); + await tester.tap(find.text('Completed')); + await settle(tester); + + await tester.tap(find.text('Salem, Tamil Nadu')); + await settle(tester); + + expect(find.text('Delivered'), findsWidgets); + expect(find.text('Total paid'), findsOneWidget); + expect(find.text('₹64'), findsWidgets); + + // What the Miler recorded at the door. + expect(find.text('COLLECTED AT YOUR DOOR'), findsOneWidget); + expect(find.text('Miler verified'), findsOneWidget); + // Once on the parcel block, once in the payment breakdown. + expect(find.text('1.6 kg'), findsNWidgets(2)); + expect(find.text('Verified weight'), findsOneWidget); + expect(find.text('1 of 1'), findsOneWidget); + expect(find.textContaining('Weighed and photographed by'), findsOneWidget); + await drainToasts(tester); + }); + + testWidgets('key screens survive large accessibility text', (tester) async { + // Any overflow throws, so simply building these screens is the assertion. + tester.platformDispatcher.textScaleFactorTestValue = 1.8; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + await signIn(tester); // login + OTP + expect(find.text('Start booking'), findsOneWidget); // home + + await openDestination(tester); // pickup point + destination + await pickPlace(tester, 'Tamil Nadu', 'Chennai'); + expect(find.text('Chennai, Tamil Nadu'), findsOneWidget); + + await tester.tap(find.text('Next')); + await settle(tester); // pickup time + await tester.tap(find.text('2:00 – 4:00 PM').first); + await tester.pump(); + await tester.tap(find.text('Review booking')); + await settle(tester); // review + expect(find.text('Review pickup details'), findsOneWidget); + + await tester.tap(find.text('Book pickup')); + await settle(tester); // confirmation + await tester.tap(find.text('Track pickup')); + await settle(tester); // tracking + expect(find.text('Pickup booked'), findsWidgets); + + await drainToasts(tester); + }); + + testWidgets('sign up survives large accessibility text', (tester) async { + tester.platformDispatcher.textScaleFactorTestValue = 1.8; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + tester.view.physicalSize = const Size(1230, 9000); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(const DoormileApp()); + await settle(tester); + await tester.tap(find.text('Create account')); + await settle(tester); + + // Building it is the assertion: any overflow in the hero or the form + // throws. + expect(find.text('Create your account.'), findsOneWidget); + await drainToasts(tester); + }); + + testWidgets('orders and account survive large accessibility text', + (tester) async { + tester.platformDispatcher.textScaleFactorTestValue = 1.8; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + await signIn(tester); + await tester.tap(find.text('Orders')); + await settle(tester); + await tester.tap(find.text('Completed')); + await settle(tester); + await tester.tap(find.text('Salem, Tamil Nadu')); + await settle(tester); // receipt + + await drainToasts(tester); + }); + + testWidgets('orders tabs filter by status', (tester) async { + final state = AppState(); + await signIn(tester, state: state); + + await tester.tap(find.text('Orders')); + await settle(tester); + // Each tab carries its own count, inside the header's segmented control — + // and the figure is the rows that tab will actually show, which for a + // collected multi-destination pickup is one per destination. + expect( + find.descendant( + of: find.byType(DmSegmented), + matching: find.text('${state.entriesOf(BookingStatus.active).length}'), + ), + findsOneWidget, + ); + + await tester.tap(find.text('Cancelled')); + await settle(tester); + expect(find.text('Erode, Tamil Nadu'), findsOneWidget); + await drainToasts(tester); + }); +} diff --git a/test/design_snapshot_test.dart b/test/design_snapshot_test.dart new file mode 100644 index 0000000..34c3932 --- /dev/null +++ b/test/design_snapshot_test.dart @@ -0,0 +1,170 @@ +@Tags(['snapshot']) +library; + +import 'package:doormile_cx/data/dev_doormile_api.dart'; +import 'package:doormile_cx/data/doormile_api.dart'; +import 'package:doormile_cx/data/location_service.dart'; +import 'package:doormile_cx/main.dart'; +import 'package:doormile_cx/state/app_state.dart'; +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Renders each screen at phone size and writes it to `test/snapshots/`. +/// +/// This is a **design** check, not a regression check. A deliberate redesign +/// would fail every one of these, which is worthless as a test signal, so the +/// `snapshot` tag is skipped in a normal run (see `dart_test.yaml`) and the +/// pictures are rendered on demand: +/// +/// flutter test test/design_snapshot_test.dart --run-skipped --update-goldens +/// +/// It exists because the only honest way to review a visual rebuild is to look +/// at it, and this produces the same pictures on any machine, with the real +/// fonts, without a device attached. +/// +/// The real fonts are loaded first — without that, Flutter's test harness draws +/// everything in Ahem's black boxes and the pictures are worthless. +Future _loadFonts() async { + for (final (family, asset) in const [ + ('Manrope', 'assets/fonts/Manrope-VF.ttf'), + ('GeistMono', 'assets/fonts/GeistMono-VF.ttf'), + // The icon font too, or every glyph in the picture is a hollow box. + ('packages/lucide_icons_flutter/Lucide', + 'packages/lucide_icons_flutter/assets/lucide.ttf'), + ]) { + final loader = FontLoader(family)..addFont(rootBundle.load(asset)); + await loader.load(); + } +} + +Future _settle(WidgetTester tester, [int ms = 800]) async { + await tester.pump(); + await tester.pump(Duration(milliseconds: ms)); + await tester.pump(const Duration(milliseconds: 400)); +} + +Future _shot(WidgetTester tester, String name) async { + await expectLater( + find.byType(MaterialApp), + matchesGoldenFile('snapshots/$name.png'), + ); +} + +void main() { + late DevDoormileApi api; + + setUp(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (call) async => '/tmp', + ); + api = DevDoormileApi(); + DoormileApi.overrideInstance(api); + LocationService.instance = FixedLocationService(); + }); + + tearDown(() => DoormileApi.overrideInstance(null)); + + testWidgets('screens', (tester) async { + await _loadFonts(); + + // One phone, portrait: 390x844 at 3x, the frame the design was drawn in. + tester.view.physicalSize = const Size(1170, 2532); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + final state = AppState(); + await tester.pumpWidget(DoormileApp(initialState: state)); + await _settle(tester); + + // Image.asset resolves off the frame loop, so in a test it never paints + // unless it is decoded first — without this the wordmark is a blank gap in + // every picture of the entrance. + await tester.runAsync(() async { + await precacheImage( + const AssetImage('assets/images/doormile-logo.png'), + tester.element(find.byType(MaterialApp)), + ); + }); + await _settle(tester); + await _shot(tester, '01-sign-in'); + + await tester.tap(find.text('Create account')); + await _settle(tester); + await _shot(tester, '01b-sign-up'); + await tester.tap(find.byIcon(LucideIcons.arrowLeft).first); + await _settle(tester); + + await tester.enterText(find.byType(TextField).first, '9876543210'); + await _settle(tester, 200); + await tester.tap(find.text('Continue')); + await _settle(tester); + await _shot(tester, '02-verify'); + + final boxes = find.byType(TextField); + for (var i = 0; i < 4; i++) { + await tester.enterText(boxes.at(i), '1'); + await tester.pump(); + } + await _settle(tester); + await _shot(tester, '03-home'); + + await tester.tap(find.text('Orders')); + await _settle(tester); + await _shot(tester, '04-orders'); + + await tester.tap(find.text('Account')); + await _settle(tester); + await _shot(tester, '05-account'); + + await tester.tap(find.text('Home')); + await _settle(tester); + await tester.tap(find.text('Miler assigned')); + await _settle(tester); + await _shot(tester, '06-tracking'); + await tester.tap(find.byIcon(LucideIcons.arrowLeft).first); + await _settle(tester); + + // Booking flow. + await tester.tap(find.text('Start booking')); + await _settle(tester); + await _shot(tester, '07-pickup-point'); + await tester.tap(find.text('Next')); + await _settle(tester); + await tester.tap(find.text('Tamil Nadu')); + await _settle(tester); + await tester.tap(find.text('Chennai')); + await _settle(tester); + await _shot(tester, '08-destination'); + + await tester.tap(find.text('Next')); + await _settle(tester); + await tester.tap(find.text('2:00 – 4:00 PM').first); + await _settle(tester); + await _shot(tester, '09-slot'); + + await tester.tap(find.text('Review booking')); + await _settle(tester); + await _shot(tester, '10-review'); + + await tester.tap(find.text('Book pickup')); + await _settle(tester); + await _shot(tester, '11-booked'); + + // A finished order, for the receipt treatment. + await tester.tap(find.text('Back to home')); + await _settle(tester); + await tester.tap(find.text('Orders')); + await _settle(tester); + await tester.tap(find.text('Completed')); + await _settle(tester); + await tester.tap(find.text('Salem, Tamil Nadu')); + await _settle(tester); + await _shot(tester, '12-receipt'); + + await tester.pump(const Duration(seconds: 3)); + }); +} diff --git a/test/flow_test.dart b/test/flow_test.dart deleted file mode 100644 index b46a1c8..0000000 --- a/test/flow_test.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:doormile/main.dart'; - -/// Drives the app through its main flows at a phone-sized viewport to catch -/// layout overflows and runtime exceptions on each screen. -void main() { - testWidgets('full user journey renders without errors', (WidgetTester tester) async { - tester.view.physicalSize = const Size(1170, 2532); // iPhone-ish @3x - tester.view.devicePixelRatio = 3.0; - addTearDown(tester.view.reset); - - await tester.pumpWidget(const DoormileApp()); - - // Splash -> Onboarding - await tester.pump(const Duration(seconds: 3)); - await tester.pumpAndSettle(); - expect(find.text('Send a parcel in one touch'), findsOneWidget); - - // Skip onboarding -> Login - await tester.tap(find.text('Skip')); - await tester.pumpAndSettle(); - expect(find.text('Welcome back'), findsOneWidget); - - // Enter phone and request OTP - await tester.enterText(find.byType(TextField).first, '9876543210'); - await tester.pump(); - await tester.tap(find.text('Send OTP')); - await tester.pump(const Duration(milliseconds: 1400)); - await tester.pumpAndSettle(); - expect(find.text('Verify your number'), findsOneWidget); - - // Fill the 6 OTP boxes - final otpBoxes = find.byType(TextField); - for (int i = 0; i < 6; i++) { - await tester.enterText(otpBoxes.at(i), '1'); - await tester.pump(); - } - await tester.tap(find.text('Verify & Continue')); - await tester.pump(const Duration(milliseconds: 1300)); - await tester.pumpAndSettle(); - - // Home - expect(find.text('Send a parcel'), findsOneWidget); - - // Start a booking - await tester.tap(find.text('Book Now')); - await tester.pumpAndSettle(); - expect(find.text('Step 1: Confirm Pickup'), findsOneWidget); - - await tester.tap(find.text('Continue')); - await tester.pumpAndSettle(); - expect(find.text('What are you sending?'), findsOneWidget); - - await tester.tap(find.text('Continue')); - await tester.pumpAndSettle(); - expect(find.text('CHOOSE SERVICE'), findsOneWidget); - - // Confirm & Continue -> review - await tester.tap(find.textContaining('Confirm & Continue')); - await tester.pumpAndSettle(); - expect(find.text('Review Booking'), findsWidgets); - - // Pop all the way back to the home shell. - for (int i = 0; i < 4; i++) { - if (find.text('Send a parcel').evaluate().isNotEmpty) break; - await tester.pageBack(); - await tester.pumpAndSettle(); - } - expect(find.text('Send a parcel'), findsOneWidget); - - // Wallet tab - await tester.tap(find.text('Wallet').first); - await tester.pumpAndSettle(); - expect(find.text('Doormile Wallet'), findsOneWidget); - - // Profile tab - await tester.tap(find.text('Profile').first); - await tester.pumpAndSettle(); - expect(find.text('Riya Sharma'), findsOneWidget); - - // Track tab (orders list) - await tester.tap(find.text('Track').first); - await tester.pumpAndSettle(); - expect(find.text('My Parcels'), findsOneWidget); - }); -} diff --git a/test/live_api_wire_test.dart b/test/live_api_wire_test.dart new file mode 100644 index 0000000..35a66d2 --- /dev/null +++ b/test/live_api_wire_test.dart @@ -0,0 +1,297 @@ +import 'dart:convert'; + +import 'package:doormile_cx/data/api_client.dart'; +import 'package:doormile_cx/data/api_exception.dart'; +import 'package:doormile_cx/data/live_doormile_api.dart'; +import 'package:doormile_cx/data/models.dart'; +import 'package:doormile_cx/data/session_store.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +/// What [LiveDoormileApi] actually puts on the wire, checked field by field +/// against the customer API document. +/// +/// These are not parser tests. Every one of them exists because a plausible, +/// readable, wrong spelling — `otp` for `code`, `lat` for `latitude`, `min` for +/// `minRupees` — costs a whole feature at runtime and nothing at compile time. +/// +/// Where the written contract and the running server disagree, these tests +/// follow the server. The `otp`/`code` case below is why. + +class _Sent { + http.Request? last; + Map get body => + jsonDecode(last!.body) as Map; +} + +({LiveDoormileApi api, _Sent sent}) _api(Object responseData) { + final sent = _Sent(); + final http.Client transport = MockClient((request) async { + sent.last = request; + return http.Response( + jsonEncode({'success': true, 'data': responseData}), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + return ( + api: LiveDoormileApi( + client: ApiClient(httpClient: transport, sessions: MemorySessionStore()), + ), + sent: sent, + ); +} + +void main() { + // ── This test used to assert the opposite, and that is why the bug shipped ── + // + // The written contract names this field `otp`. The server does not: + // CxVerifyOtp reads `json:"code"` and treats a body without it as an empty + // code, answering 400 "Enter the code we sent you" to every sign-in — with + // any code, correct or not. Verified against production: `otp` gets that 400, + // `code` gets 401 "That code did not match" for a wrong code, and a session + // for a right one. + // + // The document was wrong and this test agreed with it, so nothing failed + // until a human tried to log in. The assertion is inverted deliberately — + // the server is the contract. + test('OTP verification sends `code` — the field the server actually reads', () async { + final t = _api({ + 'accessToken': 'a', + 'refreshToken': 'r', + 'expiresIn': 3600, + 'customer': {'id': 1042, 'name': 'Alex Kumar', 'phone': '+919876543210'}, + }); + + final customer = await t.api.verifyOtp('9876543210', '1234'); + + expect(t.sent.last!.url.path, endsWith('/customer/auth/otp/verify')); + expect(t.sent.body['code'], '1234'); + expect(t.sent.body['identifier'], '+919876543210'); + expect( + t.sent.body.containsKey('otp'), + isFalse, + reason: 'the server ignores `otp` and reads the body as an empty code', + ); + expect(t.sent.last!.headers['Idempotency-Key'], isNotNull); + expect(customer.name, 'Alex Kumar'); + }); + + test('a fare estimate prices packages, and reads the rupee band back', () async { + final t = _api({ + 'minRupees': 240, + 'maxRupees': 310, + 'routeKm': 348.5, + 'breakdown': {'baseFare': 180, 'additionalStopsUplift': 60}, + }); + + final fare = await t.api.estimateFare( + pickup: const Place(title: 'Home', sub: '', lat: 13.0827, lng: 80.2707), + destinations: [ + DestinationGroup( + destination: Destination(stateCode: 'TN', districtCode: 'CHN'), + packageCount: 2, + ), + ], + ); + + final pickup = t.sent.body['pickup'] as Map; + expect(pickup['latitude'], 13.0827); + expect(pickup['longitude'], 80.2707); + + final destination = + (t.sent.body['destinations'] as List).single as Map; + expect(destination['stateCode'], 'TN'); + // One entry per package, and no weight on any of them — the customer is + // never asked what a parcel weighs. + expect((destination['packages'] as List).length, 2); + + expect(fare.min, 240); + expect(fare.max, 310); + expect(fare.routeKm, 348.5); + }); + + test('a booking sends the contract shape, flat and with a contact', () async { + final t = _api({ + 'reference': 'DM-482913', + 'stage': 'booked', + 'status': 'active', + 'cancellable': true, + 'createdAt': 1788775499000, + 'slotId': 'slot_20260908_t2', + 'pickup': { + 'title': 'Home', + 'sub': 'Flat 4B, Green Towers', + 'latitude': 13.0827, + 'longitude': 80.2707, + }, + 'destinations': [ + { + 'index': 0, + 'stateName': 'Tamil Nadu', + 'districtName': 'Chennai', + 'packageCount': 1, + 'codAmount': 450, + 'trackingId': null, + 'stage': null, + }, + ], + }); + + await t.api.client.adoptSession( + Session( + accessToken: 'a', + refreshToken: 'r', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + customer: const Customer( + id: '1042', + name: 'Alex Kumar', + phone: '+919876543210', + email: '', + ), + ), + ); + + final group = DestinationGroup( + destination: Destination(stateCode: 'TN', districtCode: 'CHN'), + ); + group.details.update( + recipientName: 'Priya S', + building: '12/A', + street: 'MG Road', + instructions: 'Handle with care', + ); + + final booking = await t.api.createBooking( + pickup: const Place( + title: 'Home', + sub: 'Flat 4B, Green Towers', + lat: 13.0827, + lng: 80.2707, + ), + destinations: [group], + slotId: 'slot_20260908_t2', + ); + + final body = t.sent.body; + final pickup = body['pickup'] as Map; + expect(pickup['latitude'], 13.0827); + expect(pickup['contactName'], 'Alex Kumar'); + expect(pickup['contactPhone'], '+919876543210'); + + final destination = + (body['destinations'] as List).single as Map; + expect(destination['recipientName'], 'Priya S'); + expect(destination['building'], '12/A'); + // Flat, not nested under `details`. + expect(destination.containsKey('details'), isFalse); + // And the note is not repeated here — it is the visit's `remarks`. + expect(destination.containsKey('instructions'), isFalse); + + // Per-destination instructions become the visit's one `remarks` line. + expect(body['remarks'], 'Handle with care'); + + // And the response — which names the state and district but sends no + // codes — must still render as a destination. + expect(booking.reference, 'DM-482913'); + expect(booking.pickup.lat, 13.0827); + final read = booking.destinations.single; + expect(read.destination.label, 'Chennai, Tamil Nadu'); + expect(read.index, 0); + expect(read.codAmount, 450); + expect(read.trackingId, isNull); + }); + + test('the cancel window closes when the Miler arrives', () { + final t = _api(const {}); + expect(t.api.isCancellable(JourneyStage.onTheWay), isTrue); + expect(t.api.isCancellable(JourneyStage.arrived), isFalse); + expect(t.api.isCancellable(JourneyStage.pickedUp), isFalse); + }); + + test('booking limits carry the COD cap', () { + const limits = BookingLimits(); + expect(limits.allowsCod, isFalse); + expect( + BookingLimits.fromJson(const { + 'maxDestinations': 1, + 'maxPackages': 20, + 'maxCodAmount': 5000, + }).maxCodAmount, + 5000, + ); + }); + + group('error codes', () { + /// Drives a real failure through [ApiClient], because the translation that + /// matters happens while the response is being decoded. + Future failing(int status, String code) async { + final client = ApiClient( + httpClient: MockClient( + (_) async => http.Response( + jsonEncode({ + 'success': false, + 'error': {'code': code, 'message': 'Something the server said'}, + }), + status, + headers: {'content-type': 'application/json'}, + ), + ), + sessions: MemorySessionStore(), + ); + try { + await client.get('/bookings', authenticated: false); + } on ApiException catch (e) { + return e; + } + fail('expected $code to throw'); + } + + test('the contract spells its codes in capitals; the client translates', () async { + expect((await failing(401, 'UNAUTHORIZED')).isAuthFailure, isTrue); + expect((await failing(409, 'SLOT_CAPACITY_FULL')).needsFreshSlots, isTrue); + expect((await failing(400, 'SLOT_EXPIRED')).needsFreshSlots, isTrue); + expect( + (await failing(409, 'BOOKING_NOT_CANCELLABLE')).code, + ApiException.notCancellable, + ); + expect( + (await failing(422, 'UNSERVICEABLE_PINCODE')).code, + ApiException.unserviceable, + ); + expect((await failing(429, 'RATE_LIMITED')).isTransient, isTrue); + // The raw code is kept for the log line and the support report. + expect((await failing(401, 'UNAUTHORIZED')).serverCode, 'UNAUTHORIZED'); + // An unknown code still lands somewhere useful: the HTTP status. + expect((await failing(404, 'NO_SUCH_THING')).code, ApiException.notFound); + }); + }); + + test('a paged list is read through its wrapper as well as beside it', () async { + final client = ApiClient( + httpClient: MockClient( + (_) async => http.Response( + jsonEncode({ + 'success': true, + 'data': { + 'items': [ + {'reference': 'DM-1'}, + ], + 'nextCursor': 'cur_2', + 'total': 9, + }, + }), + 200, + headers: {'content-type': 'application/json'}, + ), + ), + sessions: MemorySessionStore(), + ); + + final page = await client.get('/bookings', authenticated: false); + expect(page.rows.single['reference'], 'DM-1'); + expect(page.nextCursor, 'cur_2'); + expect(page.total, 9); + }); +} diff --git a/test/login_otp_guard_test.dart b/test/login_otp_guard_test.dart new file mode 100644 index 0000000..6ec7c9a --- /dev/null +++ b/test/login_otp_guard_test.dart @@ -0,0 +1,89 @@ +import 'package:doormile_cx/data/dev_doormile_api.dart'; +import 'package:doormile_cx/data/models.dart'; +import 'package:doormile_cx/main.dart'; +import 'package:doormile_cx/state/app_state.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The login screen must not walk a customer to the code screen when no code +/// was sent. +/// +/// `sent: false` is the server saying it accepted the request and delivered +/// nothing — the SMS gateway or the mail relay refused it. Advancing anyway +/// puts four empty boxes in front of someone for a code that does not exist, +/// and every digit they try is wrong. The failure then reads as "I typed it +/// wrong" instead of "nothing arrived", which is the wrong problem to hand +/// somebody. +/// +/// The resend control on the code screen has always honoured this flag. The +/// login screen did not, which is the asymmetry these tests pin down. + +/// The app runs continuous animations, so `pumpAndSettle` never returns. +Future settle(WidgetTester tester, [int ms = 800]) async { + await tester.pump(); + await tester.pump(Duration(milliseconds: ms)); + await tester.pump(const Duration(milliseconds: 400)); +} + +class _SendFails extends DevDoormileApi { + @override + Future sendOtp(String identifier) async => + const OtpChallenge(sent: false); +} + +class _SendSucceeds extends DevDoormileApi { + var calls = 0; + + @override + Future sendOtp(String identifier) async { + calls++; + return const OtpChallenge(sent: true); + } +} + +Future _tapContinue(WidgetTester tester, AppState state) async { + tester.view.physicalSize = const Size(1230, 9000); + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget(DoormileApp(initialState: state)); + await settle(tester); + + await tester.enterText(find.byType(TextField).first, '9876543210'); + await settle(tester, 200); + await tester.tap(find.text('Continue')); + await settle(tester); +} + +void main() { + testWidgets('a code that was never sent does not open the code screen', + (tester) async { + await _tapContinue(tester, AppState(api: _SendFails())); + + expect( + find.text('Verify your number'), + findsNothing, + reason: 'no code was delivered, so there is nothing to verify', + ); + expect( + // The entry screen's own headline, which now sits on the brand field + // rather than as a heading inside the sheet. + find.text('Book a pickup from your door.'), + findsOneWidget, + reason: 'the customer stays where they can try again', + ); + + // Drain the toast timer so the test ends with nothing pending. + await tester.pump(const Duration(seconds: 4)); + }); + + testWidgets('a delivered code does open the code screen', (tester) async { + final api = _SendSucceeds(); + await _tapContinue(tester, AppState(api: api)); + + expect(find.text('Verify your number'), findsOneWidget); + expect(api.calls, 1); + + await tester.pump(const Duration(seconds: 4)); + }); +} diff --git a/test/map_config_test.dart b/test/map_config_test.dart new file mode 100644 index 0000000..52ffd51 --- /dev/null +++ b/test/map_config_test.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:doormile_cx/data/map_config.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('tile provider', () { + test('CARTO serves anonymously and takes a key when one is supplied', () { + const anonymous = MapTileProvider.carto; + expect(anonymous.usable, isTrue); + expect(anonymous.hasKey, isFalse); + expect(anonymous.urlFor(DmMapStyle.streets), isNot(contains('api_key'))); + + final keyed = anonymous.withKey('abc123'); + expect(keyed.hasKey, isTrue); + expect( + keyed.urlFor(DmMapStyle.streets), + endsWith('?api_key=abc123'), + ); + // Nothing else about the source changes when the key arrives. + expect(keyed.attribution, anonymous.attribution); + expect(keyed.subdomains, anonymous.subdomains); + }); + + test('the two styles are distinct sources', () { + expect( + MapTileProvider.carto.urlFor(DmMapStyle.streets), + isNot(MapTileProvider.carto.urlFor(DmMapStyle.muted)), + ); + }); + + test('a key-only provider reports itself unusable without one', () { + expect(MapTileProvider.maptiler.usable, isFalse); + expect(MapTileProvider.maptiler.withKey('k').usable, isTrue); + expect( + MapTileProvider.maptiler.withKey('k').urlFor(DmMapStyle.streets), + endsWith('?key=k'), + ); + expect(MapTileProvider.stadia.usable, isFalse); + }); + + test('every preset credits its data source', () { + for (final provider in MapTileProvider.presets.values) { + expect( + provider.attribution, + contains('OpenStreetMap'), + reason: '${provider.id} must credit OpenStreetMap', + ); + expect(provider.attributionUrl, startsWith('https://')); + expect(provider.templates[DmMapStyle.streets], isNotNull); + } + }); + }); + + group('resolved configuration', () { + test('defaults to CARTO and identifies the app to the provider', () { + final config = DmMapConfig.fromEnvironment(); + expect(config.provider.id, 'carto'); + expect(config.warning, isNull); + expect(config.userAgent, contains('Doormile/')); + expect(config.userAgent, contains(DmMapConfig.packageName)); + expect(config.attribution, contains('CARTO')); + }); + + test('is swappable without touching a screen', () { + final original = DmMapConfig.instance; + addTearDown(() => DmMapConfig.instance = original); + + DmMapConfig.instance = DmMapConfig( + provider: MapTileProvider.osm, + userAgent: 'test/1.0', + ); + expect(DmMapConfig.instance.attribution, '© OpenStreetMap contributors'); + expect(DmMapConfig.instance.retina, isFalse); + expect(DmMapConfig.instance.maxNativeZoom, 19); + }); + }); + + test('no tile URL, subdomain list or map key lives in the UI layer', () { + // The whole point of the provider layer: swapping CARTO for a keyed plan or + // another vendor must never mean editing a map screen. + final offenders = []; + for (final entry in Directory('lib/ui').listSync(recursive: true)) { + if (entry is! File || !entry.path.endsWith('.dart')) continue; + final source = entry.readAsStringSync(); + if (source.contains('cartocdn') || + source.contains('tile.openstreetmap.org') || + source.contains('api.maptiler.com') || + source.contains('tiles.stadiamaps.com') || + source.contains('{z}/{x}/{y}')) { + offenders.add(entry.path); + } + } + expect(offenders, isEmpty, reason: 'tile URLs belong in map_config.dart'); + }); +} diff --git a/test/screens_test.dart b/test/screens_test.dart deleted file mode 100644 index dc8fd63..0000000 --- a/test/screens_test.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:provider/provider.dart'; - -import 'package:doormile/state/app_state.dart'; -import 'package:doormile/theme/app_theme.dart'; -import 'package:doormile/screens/onboarding_screen.dart'; -import 'package:doormile/screens/login_screen.dart'; -import 'package:doormile/screens/otp_screen.dart'; -import 'package:doormile/screens/main_shell.dart'; -import 'package:doormile/screens/home_screen.dart'; -import 'package:doormile/screens/wallet_screen.dart'; -import 'package:doormile/screens/profile_screen.dart'; -import 'package:doormile/screens/help_support_screen.dart'; -import 'package:doormile/screens/booking/booking_pickup_screen.dart'; -import 'package:doormile/screens/booking/parcel_details_screen.dart'; -import 'package:doormile/screens/booking/service_price_screen.dart'; -import 'package:doormile/screens/booking/booking_preview_screen.dart'; -import 'package:doormile/screens/booking/booking_success_screen.dart'; -import 'package:doormile/screens/tracking/my_orders_screen.dart'; -import 'package:doormile/screens/tracking/live_tracking_screen.dart'; -import 'package:doormile/screens/tracking/shipment_journey_screen.dart'; - -/// Renders each screen in isolation at a phone viewport and settles it, so the -/// test framework flags any overflow in the *settled* layout (no transition -/// transients). A pumped order is placed first so success/tracking have data. -Future _renderScreen(WidgetTester tester, Widget child) async { - final state = AppState() - ..login() - ..setPhone('98765 43210'); - state.placeOrder(); - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: state, - child: MaterialApp(theme: AppTheme.light, home: child), - ), - ); - await tester.pump(const Duration(milliseconds: 400)); - await tester.pump(const Duration(milliseconds: 400)); -} - -void main() { - setUp(() {}); - - final screens = { - 'Onboarding': () => const OnboardingScreen(), - 'Login': () => const LoginScreen(), - 'OTP': () => const OtpScreen(), - 'MainShell/Home': () => const MainShell(), - 'Home': () => const Scaffold(body: HomeScreen()), - 'Wallet': () => const Scaffold(body: WalletScreen()), - 'Profile': () => const Scaffold(body: ProfileScreen()), - 'Help': () => const HelpSupportScreen(), - 'BookingPickup': () => const BookingPickupScreen(), - 'ParcelDetails': () => const ParcelDetailsScreen(), - 'ServicePrice': () => const ServicePriceScreen(), - 'BookingPreview': () => const BookingPreviewScreen(), - 'BookingSuccess': () => const BookingSuccessScreen(), - 'MyOrders': () => const MyOrdersScreen(), - 'LiveTracking': () => const LiveTrackingScreen(), - 'ShipmentJourney': () => const ShipmentJourneyScreen(), - }; - - for (final entry in screens.entries) { - testWidgets('${entry.key} renders cleanly at phone size', (tester) async { - tester.view.physicalSize = const Size(1170, 2532); - tester.view.devicePixelRatio = 3.0; - addTearDown(tester.view.reset); - await _renderScreen(tester, entry.value()); - expect(tester.takeException(), isNull); - }); - } -} diff --git a/test/snapshots/01-sign-in.png b/test/snapshots/01-sign-in.png new file mode 100644 index 0000000..c91d261 Binary files /dev/null and b/test/snapshots/01-sign-in.png differ diff --git a/test/snapshots/01b-sign-up.png b/test/snapshots/01b-sign-up.png new file mode 100644 index 0000000..92ec354 Binary files /dev/null and b/test/snapshots/01b-sign-up.png differ diff --git a/test/snapshots/02-verify.png b/test/snapshots/02-verify.png new file mode 100644 index 0000000..78f11ea Binary files /dev/null and b/test/snapshots/02-verify.png differ diff --git a/test/snapshots/03-home.png b/test/snapshots/03-home.png new file mode 100644 index 0000000..757f4f6 Binary files /dev/null and b/test/snapshots/03-home.png differ diff --git a/test/snapshots/04-orders.png b/test/snapshots/04-orders.png new file mode 100644 index 0000000..5632c52 Binary files /dev/null and b/test/snapshots/04-orders.png differ diff --git a/test/snapshots/05-account.png b/test/snapshots/05-account.png new file mode 100644 index 0000000..678355a Binary files /dev/null and b/test/snapshots/05-account.png differ diff --git a/test/snapshots/06-tracking.png b/test/snapshots/06-tracking.png new file mode 100644 index 0000000..25f6270 Binary files /dev/null and b/test/snapshots/06-tracking.png differ diff --git a/test/snapshots/07-pickup-point.png b/test/snapshots/07-pickup-point.png new file mode 100644 index 0000000..5fa0658 Binary files /dev/null and b/test/snapshots/07-pickup-point.png differ diff --git a/test/snapshots/08-destination.png b/test/snapshots/08-destination.png new file mode 100644 index 0000000..49fc160 Binary files /dev/null and b/test/snapshots/08-destination.png differ diff --git a/test/snapshots/09-slot.png b/test/snapshots/09-slot.png new file mode 100644 index 0000000..e43be23 Binary files /dev/null and b/test/snapshots/09-slot.png differ diff --git a/test/snapshots/10-review.png b/test/snapshots/10-review.png new file mode 100644 index 0000000..a4dc53b Binary files /dev/null and b/test/snapshots/10-review.png differ diff --git a/test/snapshots/11-booked.png b/test/snapshots/11-booked.png new file mode 100644 index 0000000..40e046b Binary files /dev/null and b/test/snapshots/11-booked.png differ diff --git a/test/snapshots/12-receipt.png b/test/snapshots/12-receipt.png new file mode 100644 index 0000000..03c319a Binary files /dev/null and b/test/snapshots/12-receipt.png differ diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index d90fb7b..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:doormile/main.dart'; - -void main() { - testWidgets('App boots and advances from splash to onboarding', - (WidgetTester tester) async { - await tester.pumpWidget(const DoormileApp()); - - // Splash shows the brand wordmark. - expect(find.text('Doormile'), findsWidgets); - - // Let the splash auto-navigation timer fire, then settle on onboarding. - await tester.pump(const Duration(seconds: 3)); - await tester.pumpAndSettle(); - - expect(find.text('Send a parcel in one touch'), findsOneWidget); - }); -} diff --git a/web/index.html b/web/index.html index 134aee5..06e20b5 100644 --- a/web/index.html +++ b/web/index.html @@ -1,4 +1,6 @@ - + + + - + - + - doormile + doormile_cx - - - - - - - - - - - - \ No newline at end of file + + + diff --git a/web/manifest.json b/web/manifest.json index 1ac7b7a..8c0a396 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "doormile", - "short_name": "doormile", + "name": "doormile_cx", + "short_name": "doormile_cx", "start_url": ".", "display": "standalone", "background_color": "#0175C2", diff --git a/web/splash/img/dark-1x.png b/web/splash/img/dark-1x.png deleted file mode 100644 index 9a2c0a5..0000000 Binary files a/web/splash/img/dark-1x.png and /dev/null differ diff --git a/web/splash/img/dark-2x.png b/web/splash/img/dark-2x.png deleted file mode 100644 index 3e7236c..0000000 Binary files a/web/splash/img/dark-2x.png and /dev/null differ diff --git a/web/splash/img/dark-3x.png b/web/splash/img/dark-3x.png deleted file mode 100644 index cd98261..0000000 Binary files a/web/splash/img/dark-3x.png and /dev/null differ diff --git a/web/splash/img/dark-4x.png b/web/splash/img/dark-4x.png deleted file mode 100644 index c894515..0000000 Binary files a/web/splash/img/dark-4x.png and /dev/null differ diff --git a/web/splash/img/light-1x.png b/web/splash/img/light-1x.png deleted file mode 100644 index 9a2c0a5..0000000 Binary files a/web/splash/img/light-1x.png and /dev/null differ diff --git a/web/splash/img/light-2x.png b/web/splash/img/light-2x.png deleted file mode 100644 index 3e7236c..0000000 Binary files a/web/splash/img/light-2x.png and /dev/null differ diff --git a/web/splash/img/light-3x.png b/web/splash/img/light-3x.png deleted file mode 100644 index cd98261..0000000 Binary files a/web/splash/img/light-3x.png and /dev/null differ diff --git a/web/splash/img/light-4x.png b/web/splash/img/light-4x.png deleted file mode 100644 index c894515..0000000 Binary files a/web/splash/img/light-4x.png and /dev/null differ