Add driver app with driving mode, and mock location in the simulator
Build a separate driver app from the same codebase: APP_VARIANT=driver (app.config.ts) sets its name, bundle ID and scheme, eas.json gets driver build profiles, and Stack.Protected keeps each app to its own screens. metro.config.js keys Metro's cache by variant so switching apps never serves the other's baked-in config. Drivers get ride requests only in driving mode. While driving they see a waiting animation or, on a ride, turn-by-turn directions; requests slide up over either without disturbing it. Accepting mid-ride queues the ride (one in progress plus one queued). Going offline or online mid-round leaves or joins it. Simulated phones no longer touch the location API: each sits at a mocked position from its URL (default downtown San Francisco). Phone specs in sim.html move to named key=value options. Scenarios cover driving mode, interruptions, the ride queue and mocked location, each with a slow version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a01cb76f9e
commit
da9172e594
33 changed files with 827 additions and 176 deletions
26
app.config.ts
Normal file
26
app.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { ConfigContext, ExpoConfig } from 'expo/config';
|
||||
|
||||
// One codebase, two apps: the rider app (default) and the driver app. Pick one with
|
||||
// APP_VARIANT=driver when starting the dev server or in an EAS build profile (eas.json).
|
||||
// Static settings stay in app.json; only what differs between the apps is set here.
|
||||
const variant = process.env.APP_VARIANT === 'driver' ? 'driver' : 'rider';
|
||||
const isDriver = variant === 'driver';
|
||||
|
||||
export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
...config,
|
||||
name: isDriver ? 'RideRN Driver' : 'RideRN',
|
||||
slug: config.slug ?? 'RideRN',
|
||||
scheme: isDriver ? 'ridern-driver' : 'ridern',
|
||||
ios: {
|
||||
...config.ios,
|
||||
bundleIdentifier: isDriver ? 'com.example.ridern.driver' : 'com.example.ridern',
|
||||
},
|
||||
android: {
|
||||
...config.android,
|
||||
package: isDriver ? 'com.example.ridern.driver' : 'com.example.ridern',
|
||||
},
|
||||
extra: {
|
||||
...config.extra,
|
||||
appVariant: variant,
|
||||
},
|
||||
});
|
||||
7
app.json
7
app.json
|
|
@ -7,8 +7,7 @@
|
|||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"ios": {
|
||||
"supportsTablet": false,
|
||||
"bundleIdentifier": "com.example.ridern"
|
||||
"supportsTablet": false
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
|
|
@ -17,8 +16,7 @@
|
|||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.example.ridern"
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
|
|
@ -32,7 +30,6 @@
|
|||
}
|
||||
]
|
||||
],
|
||||
"scheme": "ridern",
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import scenario from '../scenarios/one-ride-per-driver';
|
||||
import scenario from '../scenarios/mocked-location';
|
||||
import { runScenario } from '../support/pace';
|
||||
|
||||
runScenario(scenario);
|
||||
4
cypress/e2e/offer-interrupts-driving.cy.ts
Normal file
4
cypress/e2e/offer-interrupts-driving.cy.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import scenario from '../scenarios/offer-interrupts-driving';
|
||||
import { runScenario } from '../support/pace';
|
||||
|
||||
runScenario(scenario);
|
||||
4
cypress/e2e/offline-drivers-get-no-requests.cy.ts
Normal file
4
cypress/e2e/offline-drivers-get-no-requests.cy.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import scenario from '../scenarios/offline-drivers-get-no-requests';
|
||||
import { runScenario } from '../support/pace';
|
||||
|
||||
runScenario(scenario);
|
||||
4
cypress/e2e/ride-queue-limit.cy.ts
Normal file
4
cypress/e2e/ride-queue-limit.cy.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import scenario from '../scenarios/ride-queue-limit';
|
||||
import { runScenario } from '../support/pace';
|
||||
|
||||
runScenario(scenario);
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import scenario from '../../scenarios/one-ride-per-driver';
|
||||
import scenario from '../../scenarios/mocked-location';
|
||||
import { runScenario } from '../../support/pace';
|
||||
|
||||
runScenario(scenario, 'slow');
|
||||
4
cypress/e2e/slow/offer-interrupts-driving.cy.ts
Normal file
4
cypress/e2e/slow/offer-interrupts-driving.cy.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import scenario from '../../scenarios/offer-interrupts-driving';
|
||||
import { runScenario } from '../../support/pace';
|
||||
|
||||
runScenario(scenario, 'slow');
|
||||
4
cypress/e2e/slow/offline-drivers-get-no-requests.cy.ts
Normal file
4
cypress/e2e/slow/offline-drivers-get-no-requests.cy.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import scenario from '../../scenarios/offline-drivers-get-no-requests';
|
||||
import { runScenario } from '../../support/pace';
|
||||
|
||||
runScenario(scenario, 'slow');
|
||||
4
cypress/e2e/slow/ride-queue-limit.cy.ts
Normal file
4
cypress/e2e/slow/ride-queue-limit.cy.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import scenario from '../../scenarios/ride-queue-limit';
|
||||
import { runScenario } from '../../support/pace';
|
||||
|
||||
runScenario(scenario, 'slow');
|
||||
|
|
@ -30,7 +30,8 @@ export default defineScenario('gives the ride to the first driver who accepts, a
|
|||
|
||||
cy.phone('james').find(tid('job-fare')).should('have.text', 'Fare $8.80');
|
||||
cy.phone('aisha').find(tid('driver-result')).should('have.text', 'Another driver took that ride');
|
||||
cy.phone('maria').find(tid('driver-idle')).should('exist');
|
||||
cy.phone('maria').find(tid('offer-sheet')).should('not.exist');
|
||||
cy.phone('maria').find(tid('driver-waiting')).should('exist');
|
||||
cy.phone('maria').find(tid('driver-result')).should('not.exist');
|
||||
|
||||
cy.dispatchState().then(({ rides }) => {
|
||||
|
|
|
|||
25
cypress/scenarios/mocked-location.ts
Normal file
25
cypress/scenarios/mocked-location.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineScenario, tid } from '../support/e2e';
|
||||
import { alice, maria } from '../support/phones';
|
||||
|
||||
const unionSquare = { latitude: 37.788, longitude: -122.4075 };
|
||||
|
||||
export default defineScenario('uses each phone’s mocked location instead of real GPS', () => {
|
||||
cy.intercept('GET', /photon\.komoot\.io\/api/, { fixture: 'photon-ferry-building.json' }).as('search');
|
||||
cy.intercept('GET', /router\.project-osrm\.org\/route/, { fixture: 'osrm-5km.json' }).as('route');
|
||||
|
||||
cy.resetDispatch();
|
||||
cy.openPhones([{ ...alice, location: unionSquare }, maria]);
|
||||
|
||||
// No location prompt to answer and no "Location is off" fallback: the phone just knows.
|
||||
cy.phone('alice').find(tid('where-to')).should('exist');
|
||||
cy.phone('alice').should('not.contain.text', 'Location is off');
|
||||
|
||||
cy.requestRide('alice', 'ferry', 'Ferry Building');
|
||||
|
||||
// Search is biased toward, and the route starts from, Union Square.
|
||||
cy.wait('@search').its('request.url').should('contain', 'lat=37.788').and('contain', 'lon=-122.4075');
|
||||
cy.wait('@route').its('request.url').should('contain', '/driving/-122.4075,37.788;');
|
||||
cy.waitForDispatch((s) => s.rides.length === 1, 'ride requested').then(({ rides }) => {
|
||||
expect(rides[0].pickup).to.deep.equal(unionSquare);
|
||||
});
|
||||
});
|
||||
65
cypress/scenarios/offer-interrupts-driving.ts
Normal file
65
cypress/scenarios/offer-interrupts-driving.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { defineScenario, tid } from '../support/e2e';
|
||||
import { scaled } from '../support/pace';
|
||||
import { alice, bob, james, maria } from '../support/phones';
|
||||
|
||||
export default defineScenario('interrupts a waiting driver, and a driver mid-ride, with a request', () => {
|
||||
cy.resetDispatch();
|
||||
cy.openPhones([alice, bob, maria, james]);
|
||||
|
||||
// Both drivers are driving with nothing to do yet.
|
||||
for (const driver of ['maria', 'james']) {
|
||||
cy.phone(driver).find(tid('driver-waiting')).should('exist');
|
||||
cy.phone(driver).find(tid('offer-sheet')).should('not.exist');
|
||||
}
|
||||
|
||||
// A request interrupts the waiting screen; passing goes back to waiting.
|
||||
cy.requestRide('alice', 'ferry', 'Ferry Building');
|
||||
cy.phone('maria').find(tid('offer-sheet')).should('contain.text', 'New ride request');
|
||||
cy.pass('maria');
|
||||
cy.phone('maria').find(tid('offer-sheet')).should('not.exist');
|
||||
cy.phone('maria').find(tid('driver-waiting')).should('exist');
|
||||
|
||||
// James takes Alice's ride and starts navigating to her.
|
||||
cy.accept('james');
|
||||
cy.phone('james').find(tid('driver-waiting')).should('not.exist');
|
||||
cy.phone('james').find(tid('nav-heading')).should('have.text', 'Pick up Alice');
|
||||
|
||||
// Hold on to James's navigation view, to prove the next request never replaces it.
|
||||
let navigation: HTMLElement;
|
||||
cy.phone('james')
|
||||
.find(tid('driver-navigation'))
|
||||
.then(([el]) => {
|
||||
navigation = el;
|
||||
});
|
||||
|
||||
// Mid-ride, Bob's request pops up over the directions.
|
||||
cy.requestRide('bob', 'ferry', 'Ferry Building');
|
||||
cy.phone('james').find(tid('offer-sheet')).should('contain.text', 'Accepting makes this your next ride, after Alice.');
|
||||
cy.phone('james').find(tid('driver-navigation')).should('exist');
|
||||
|
||||
// The directions keep going underneath while James decides.
|
||||
cy.phone('james')
|
||||
.find(tid('nav-instruction'))
|
||||
.invoke('text')
|
||||
.then((before) => {
|
||||
cy.phone('james').find(tid('nav-instruction'), { timeout: scaled(8000) }).should('not.have.text', before);
|
||||
});
|
||||
|
||||
// Accepting queues Bob behind Alice; the navigation to Alice carries on untouched.
|
||||
cy.pass('maria');
|
||||
cy.accept('james');
|
||||
cy.phone('james').find(tid('offer-sheet')).should('not.exist');
|
||||
cy.phone('james').find(tid('next-job')).should('have.text', 'Next: Bob → Ferry Building · $8.80');
|
||||
cy.phone('james')
|
||||
.find(tid('driver-navigation'))
|
||||
.should(([el]) => expect(el, 'navigation was never remounted').to.equal(navigation));
|
||||
cy.phone('james').find(tid('nav-heading')).should('have.text', 'Pick up Alice');
|
||||
cy.phone('bob').find(tid('driver-finishing')).should('have.text', 'James is finishing a nearby trip, then heading to you');
|
||||
|
||||
// Finishing Alice's trip moves James on to Bob.
|
||||
cy.phone('james').find(tid('complete-trip')).click();
|
||||
cy.phone('alice').find(tid('ride-status')).should('have.text', 'You’ve arrived');
|
||||
cy.phone('james').find(tid('nav-heading')).should('have.text', 'Pick up Bob');
|
||||
cy.phone('james').find(tid('next-job')).should('not.exist');
|
||||
cy.phone('bob').find(tid('driver-finishing')).should('not.exist');
|
||||
});
|
||||
35
cypress/scenarios/offline-drivers-get-no-requests.ts
Normal file
35
cypress/scenarios/offline-drivers-get-no-requests.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { defineScenario, tid } from '../support/e2e';
|
||||
import { alice, james, maria } from '../support/phones';
|
||||
|
||||
export default defineScenario('only offers rides to drivers in driving mode', () => {
|
||||
cy.resetDispatch();
|
||||
cy.openPhones([alice, { ...maria, driving: false }, james]);
|
||||
|
||||
cy.phone('maria').find(tid('driver-offline')).should('contain.text', 'You’re offline');
|
||||
cy.phone('james').find(tid('driver-waiting')).should('contain.text', 'Looking for ride requests');
|
||||
|
||||
cy.requestRide('alice', 'ferry', 'Ferry Building');
|
||||
|
||||
// Only James is driving, so only James is asked.
|
||||
cy.phone('james').find(tid('offer-sheet')).should('exist');
|
||||
cy.phone('maria').find(tid('offer-sheet')).should('not.exist');
|
||||
cy.dispatchState().then(({ rides }) => expect(rides[0].rounds[0].invited).to.deep.equal(['james']));
|
||||
|
||||
// Maria starts driving mid-round and is offered the open round straight away.
|
||||
cy.startDriving('maria');
|
||||
cy.phone('maria').find(tid('offer-price')).should('have.text', '$8.80');
|
||||
|
||||
// James goes offline: his request disappears and he's no longer counted.
|
||||
cy.goOffline('james');
|
||||
cy.phone('james').find(tid('offer-sheet')).should('not.exist');
|
||||
cy.phone('james').find(tid('driver-offline')).should('exist');
|
||||
|
||||
// With James gone, Maria passing is everyone passing: round 2 goes to Maria alone.
|
||||
cy.pass('maria');
|
||||
cy.phone('maria').find(tid('offer-price')).should('have.text', '$9.35');
|
||||
cy.phone('james').find(tid('offer-sheet')).should('not.exist');
|
||||
cy.waitForDispatch((s) => s.rides[0]?.rounds.length === 2, 'round 2 open').then(({ rides }) => {
|
||||
expect(rides[0].rounds[0].invited).to.deep.equal(['james', 'maria']);
|
||||
expect(rides[0].rounds[1].invited).to.deep.equal(['maria']);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { defineScenario, tid } from '../support/e2e';
|
||||
import { alice, bob, james, maria } from '../support/phones';
|
||||
|
||||
export default defineScenario('never gives one driver two rides at once', () => {
|
||||
cy.resetDispatch();
|
||||
cy.openPhones([alice, bob, maria, james]);
|
||||
|
||||
cy.requestRide('alice', 'ferry', 'Ferry Building');
|
||||
cy.requestRide('bob', 'ferry', 'Ferry Building');
|
||||
cy.phone('maria').find(tid('offer')).should('have.length', 2);
|
||||
|
||||
// Maria takes Bob's ride, so Alice's offer disappears from her phone.
|
||||
cy.accept('maria', { rider: 'Bob' });
|
||||
cy.phone('bob').find(tid('driver-name')).should('contain.text', 'Maria');
|
||||
cy.phone('maria').find(tid('driver-job')).should('contain.text', 'Bob');
|
||||
cy.phone('maria').find(tid('offer')).should('not.exist');
|
||||
|
||||
// Alice's round now only waits on James. When he passes, the next round skips Maria.
|
||||
cy.pass('james', { rider: 'Alice' });
|
||||
cy.phone('james').find(tid('offer-price')).should('have.text', '$9.35');
|
||||
cy.accept('james', { rider: 'Alice' });
|
||||
|
||||
cy.phone('alice').find(tid('driver-name')).should('contain.text', 'James');
|
||||
cy.phone('alice').find(tid('ride-fare')).should('have.text', '$9.35');
|
||||
cy.waitForDispatch((s) => s.rides.every((r) => r.status === 'matched'), 'both rides matched').then(({ rides }) => {
|
||||
expect(rides.map((r) => [r.riderId, r.winnerId])).to.deep.equal([
|
||||
['alice', 'james'],
|
||||
['bob', 'maria'],
|
||||
]);
|
||||
expect(rides[0].rounds[1].invited).to.deep.equal(['james']);
|
||||
});
|
||||
});
|
||||
34
cypress/scenarios/ride-queue-limit.ts
Normal file
34
cypress/scenarios/ride-queue-limit.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { defineScenario, tid } from '../support/e2e';
|
||||
import { alice, bob, carol, james, maria } from '../support/phones';
|
||||
|
||||
export default defineScenario('lets a driver queue one ride behind the current one, and no more', () => {
|
||||
cy.resetDispatch();
|
||||
cy.openPhones([alice, bob, carol, maria, james]);
|
||||
|
||||
cy.requestRide('alice', 'ferry', 'Ferry Building');
|
||||
cy.requestRide('bob', 'ferry', 'Ferry Building');
|
||||
cy.phone('maria').find(tid('offer')).should('have.length', 2);
|
||||
|
||||
// Maria takes Bob's ride, and is still offered Alice's as her next one.
|
||||
cy.accept('maria', { rider: 'Bob' });
|
||||
cy.phone('maria').find(tid('nav-heading')).should('have.text', 'Pick up Bob');
|
||||
cy.phone('maria').find(tid('offer')).should('have.length', 1);
|
||||
cy.accept('maria', { rider: 'Alice' });
|
||||
cy.phone('maria').find(tid('next-job')).should('contain.text', 'Next: Alice');
|
||||
cy.phone('alice').find(tid('driver-finishing')).should('contain.text', 'Maria is finishing a nearby trip');
|
||||
cy.phone('bob').find(tid('driver-finishing')).should('not.exist');
|
||||
|
||||
// With a ride in progress and one queued, Maria is full: Carol's request skips her.
|
||||
cy.requestRide('carol', 'ferry', 'Ferry Building');
|
||||
cy.phone('james').find(tid('offer-destination')).should('have.text', 'Carol → Ferry Building');
|
||||
cy.phone('maria').find(tid('offer-sheet')).should('not.exist');
|
||||
cy.waitForDispatch((s) => s.rides.length === 3, 'three requests').then(({ rides, devices }) => {
|
||||
expect(rides[2].rounds[0].invited).to.deep.equal(['james']);
|
||||
expect(devices.find((d) => d.id === 'maria')?.jobs).to.deep.equal(['ride-2', 'ride-1']);
|
||||
});
|
||||
|
||||
// Finishing Bob's trip makes room again, so Maria joins Carol's open round.
|
||||
cy.phone('maria').find(tid('complete-trip')).click();
|
||||
cy.phone('maria').find(tid('nav-heading')).should('have.text', 'Pick up Alice');
|
||||
cy.phone('maria').find(tid('offer-destination')).should('have.text', 'Carol → Ferry Building');
|
||||
});
|
||||
|
|
@ -12,6 +12,10 @@ export type PhoneSpec = {
|
|||
car?: string;
|
||||
plate?: string;
|
||||
rating?: number;
|
||||
/** Where the phone's mocked GPS puts it; downtown San Francisco if left out. */
|
||||
location?: { latitude: number; longitude: number };
|
||||
/** Drivers only: start in driving mode (the default) or offline. */
|
||||
driving?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -24,13 +28,14 @@ export const defineScenario = (title: string, run: () => void): Scenario => ({ t
|
|||
|
||||
export type DispatchState = {
|
||||
policy: AuctionPolicy;
|
||||
devices: (PhoneSpec & { online: boolean; busy: boolean })[];
|
||||
devices: (PhoneSpec & { online: boolean; driving: boolean; jobs: string[] })[];
|
||||
rides: {
|
||||
id: string;
|
||||
riderId: string;
|
||||
status: 'auction' | 'matched' | 'completed' | 'unmatched' | 'cancelled';
|
||||
destination: string;
|
||||
quotedFare: number;
|
||||
pickup: { latitude: number; longitude: number };
|
||||
rounds: {
|
||||
number: number;
|
||||
price: number;
|
||||
|
|
@ -75,6 +80,10 @@ declare global {
|
|||
pausePhone(id: string): Chainable<void>;
|
||||
/** Send the phone's queued messages in order, then deliver what it missed. */
|
||||
resumePhone(id: string): Chainable<void>;
|
||||
/** Driver taps Start driving, and dispatch has registered it. */
|
||||
startDriving(driverId: string): Chainable<void>;
|
||||
/** Driver taps Go offline, and dispatch has registered it. */
|
||||
goOffline(driverId: string): Chainable<void>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,9 +131,20 @@ Cypress.Commands.add('waitForDispatch', (check, description) => {
|
|||
});
|
||||
|
||||
Cypress.Commands.add('openPhones', (phones) => {
|
||||
const specs = phones.map((p) =>
|
||||
[p.role, p.id, p.name, p.car ?? '', p.plate ?? '', p.rating ?? ''].map((part) => encodeURIComponent(String(part))).join(':'),
|
||||
);
|
||||
const specs = phones.map((p) => {
|
||||
const options = {
|
||||
car: p.car,
|
||||
plate: p.plate,
|
||||
rating: p.rating,
|
||||
lat: p.location?.latitude,
|
||||
lng: p.location?.longitude,
|
||||
driving: p.driving === false ? 0 : undefined,
|
||||
};
|
||||
const extras = Object.entries(options)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => `${key}=${value}`);
|
||||
return [p.role, p.id, p.name, ...extras].map((part) => encodeURIComponent(String(part))).join(':');
|
||||
});
|
||||
cy.visit(`/sim.html?phones=${encodeURIComponent(specs.join(','))}`);
|
||||
|
||||
// The first load can wait on Metro compiling the web bundle.
|
||||
|
|
@ -135,12 +155,13 @@ Cypress.Commands.add('openPhones', (phones) => {
|
|||
expect(body?.contentDocument?.querySelector(ready), `${p.id} rendered`).to.exist;
|
||||
}
|
||||
});
|
||||
const drives = (p: PhoneSpec) => p.role === 'driver' && p.driving !== false;
|
||||
for (const p of phones) {
|
||||
if (p.role === 'driver') cy.phone(p.id).find(tid('driver-status')).should('have.text', 'Online');
|
||||
if (p.role === 'driver') cy.phone(p.id).find(tid('driver-status')).should('have.text', drives(p) ? 'Driving' : 'Offline');
|
||||
}
|
||||
cy.waitForDispatch(
|
||||
(state) => phones.every((p) => state.devices.some((d) => d.id === p.id && d.online)),
|
||||
'all phones connected',
|
||||
(state) => phones.every((p) => state.devices.some((d) => d.id === p.id && d.online && d.driving === drives(p))),
|
||||
'all phones connected, drivers in their starting mode',
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -210,3 +231,15 @@ Cypress.Commands.add('resumePhone', (id) => {
|
|||
dispatchLink(id).then((link) => link.release());
|
||||
markPaused(id, false);
|
||||
});
|
||||
|
||||
function setDriving(driverId: string, driving: boolean) {
|
||||
cy.phone(driverId).find(tid(driving ? 'start-driving' : 'stop-driving')).click();
|
||||
cy.phone(driverId).find(tid('driver-status')).should('have.text', driving ? 'Driving' : 'Offline');
|
||||
cy.waitForDispatch(
|
||||
(state) => state.devices.some((d) => d.id === driverId && d.driving === driving),
|
||||
`${driverId} ${driving ? 'driving' : 'offline'}`,
|
||||
);
|
||||
}
|
||||
|
||||
Cypress.Commands.add('startDriving', (driverId) => setDriving(driverId, true));
|
||||
Cypress.Commands.add('goOffline', (driverId) => setDriving(driverId, false));
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ export const bob: PhoneSpec = { role: 'rider', id: 'bob', name: 'Bob' };
|
|||
export const maria: PhoneSpec = { role: 'driver', id: 'maria', name: 'Maria', car: 'Toyota Camry · Silver', plate: '7KDL294', rating: 4.95 };
|
||||
export const james: PhoneSpec = { role: 'driver', id: 'james', name: 'James', car: 'Honda Accord · Black', plate: '8TRW115', rating: 4.88 };
|
||||
export const aisha: PhoneSpec = { role: 'driver', id: 'aisha', name: 'Aisha', car: 'Tesla Model 3 · White', plate: '9EVA662', rating: 4.97 };
|
||||
export const carol: PhoneSpec = { role: 'rider', id: 'carol', name: 'Carol' };
|
||||
|
|
|
|||
|
|
@ -15,6 +15,27 @@ This setup runs several "phones" at once in one browser, without iOS simulators,
|
|||
Cypress: drives the iframes ─────────┘ cy.request: reset / inspect
|
||||
```
|
||||
|
||||
## Two apps: rider and driver
|
||||
|
||||
The codebase builds two apps, distributed separately:
|
||||
|
||||
| App | Name | Bundle ID / package | Scheme | Screens |
|
||||
| ------------- | --------------- | ---------------------------- | --------------- | --------------------------- |
|
||||
| Rider (default) | RideRN | `com.example.ridern` | `ridern` | home, search, finding, trip, trips |
|
||||
| Driver | RideRN Driver | `com.example.ridern.driver` | `ridern-driver` | driver |
|
||||
|
||||
`APP_VARIANT=driver` selects the driver app (`app.config.ts`, on top of `app.json`). The root layout guards each app's screens with `Stack.Protected`, so the driver app can't reach rider screens and vice versa; opening a guarded route, including `/` in the driver app, lands on the first allowed screen.
|
||||
|
||||
```bash
|
||||
npm start # rider app dev server
|
||||
npm run start:driver # driver app dev server
|
||||
npx eas-cli@latest build --profile driver-production # driver app store build
|
||||
```
|
||||
|
||||
`eas.json` has `development`, `preview` and `production` profiles for the rider app, and `driver-development`, `driver-preview` and `driver-production` for the driver app.
|
||||
|
||||
In the simulator, both apps run from one dev server: each phone's `role` URL parameter picks which app it is.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
|
|
@ -41,12 +62,12 @@ Then open <http://localhost:8090/sim.html?phones=rider:alice:Alice,driver:maria:
|
|||
The dispatch server runs a rising-price auction in rounds:
|
||||
|
||||
1. A rider requests a ride. The fare shown on the ride-options sheet becomes the **quote**.
|
||||
2. Round 1 opens. Every driver who is **online and not on a trip** is offered the same **suggested fare** and taps **Accept** or **Pass**. A driver who comes online mid-round is offered that round too.
|
||||
2. Round 1 opens. Every **connected driver in driving mode with room for another ride** is offered the same **suggested fare** and taps **Accept** or **Pass**. A driver who becomes available mid-round (starts driving, reconnects, or finishes a trip) is offered that round too.
|
||||
3. The **first driver to accept wins** at that round's price, and the auction ends immediately.
|
||||
4. A round ends with no taker when every driver in it has passed (or is now on another trip), or when its time runs out.
|
||||
4. A round ends with no taker when every driver in it has passed (or has since gone offline or filled up), or when its time runs out.
|
||||
5. Then the next round opens at a higher price and is offered again to every available driver, including those who passed.
|
||||
6. When a round at the **price cap** ends with no taker, the ride is `unmatched` and the rider sees "No drivers available". With no cap, rounds continue until someone accepts or the rider cancels.
|
||||
7. The winning driver taps **Complete trip**. The rider's phone moves to "You've arrived", and the trip is saved to that rider's history.
|
||||
7. The winning driver taps **Complete trip**. The rider's phone moves to "You've arrived", and the trip is saved to that rider's history. If the driver has a ride queued, it becomes their current one.
|
||||
|
||||
Ride statuses: `auction` → `matched` → `completed`, or `auction` → `unmatched` / `cancelled`.
|
||||
|
||||
|
|
@ -54,6 +75,23 @@ Each accept or pass names the round it answers. If the price moved on in the mea
|
|||
|
||||
When two drivers accept the same round, **whichever accept reaches the server first wins**. Later ones get "Another driver already took this ride" and are recorded on the round as `lateAccepts`, in arrival order.
|
||||
|
||||
### Driving mode
|
||||
|
||||
Drivers only get ride requests while in **driving mode**:
|
||||
|
||||
- **Offline:** "You're offline" with a **Start driving** button. No requests arrive. The driver app always starts offline.
|
||||
- **Driving, no ride:** a "Looking for ride requests" animation.
|
||||
- **Driving, on a ride:** turn-by-turn directions to the rider. There's no real routing yet: `src/lib/navigation.ts` holds a simulated route whose steps advance every 5 s.
|
||||
|
||||
A ride request can arrive in either driving state. It **interrupts** whatever is on screen: a sheet slides up over the waiting animation or the directions, and the directions stay mounted and keep advancing underneath.
|
||||
|
||||
- **Pass** dismisses the sheet and leaves a small "You passed at $X" note. If a later round raises the price, the sheet pops up again.
|
||||
- **Accept** while waiting starts that ride: directions to the pickup.
|
||||
- **Accept** while on a ride **queues** it as the driver's next ride ("Next: Bob → …"); the current directions carry on untouched. The queued rider sees "James is finishing a nearby trip, then heading to you". A driver can hold **one ride in progress plus one queued** (`MAX_RIDES_PER_DRIVER`); when full, they get no requests until they complete a trip.
|
||||
- **Go offline** stops new requests at any time, including from the request sheet. Rides already accepted continue. Going offline mid-round drops the driver from it; starting mid-round joins it.
|
||||
|
||||
The phone owns the setting and tells dispatch on every connect (`setDriving`), so a reconnect never silently changes it. `POST /test/reset` sets every driver back to offline until their phone says otherwise.
|
||||
|
||||
### Pricing policy
|
||||
|
||||
There's no real pricing model yet, so the auction is driven by a configurable **policy**. Prices are multiples of the rider's quote:
|
||||
|
|
@ -81,7 +119,7 @@ What each driver sees after a ride is decided:
|
|||
| Didn't answer and another driver accepted | "Another driver took that ride" |
|
||||
| Didn't answer the final round | "That request closed before you answered" |
|
||||
| Rider cancelled | "The rider cancelled their request" |
|
||||
| Passed | nothing (back to "Waiting for ride requests") |
|
||||
| Passed | nothing (back to what they were doing) |
|
||||
| Won, then completed the trip | "Trip complete · you earned $X" |
|
||||
|
||||
## Pieces
|
||||
|
|
@ -90,16 +128,22 @@ What each driver sees after a ride is decided:
|
|||
| -------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `server/dispatch.mts` | Dispatch server: WebSocket for phones, HTTP for state and test control. Run directly by Node (type stripping, no build step). |
|
||||
| `src/lib/dispatch/protocol.ts` | Message and snapshot types shared by the app and the server. |
|
||||
| `src/lib/dispatch/device.ts` | Who this phone is (from the URL on web) and where dispatch lives. |
|
||||
| `src/lib/dispatch/device.ts` | Which app this is, who this phone is (from the URL in the simulator), and where dispatch lives. |
|
||||
| `src/lib/dispatch/DispatchContext.tsx` | WebSocket client: connects, says hello, reconnects, exposes the latest snapshot. Also the pause hook (below). |
|
||||
| `src/lib/dispatch/testHooks.ts` | Types for the test hooks on each phone's `window`. |
|
||||
| `src/lib/RideContext.tsx` | Rider state: `requestRide`, `cancelRide`, `dismissRide` and `completeTrip` go through dispatch. |
|
||||
| `src/app/finding.tsx` | Rider's auction / driver found / no drivers screen. |
|
||||
| `src/app/driver.tsx` | Driver screen: suggested fare with Accept / Pass, current job, result banner. |
|
||||
| `app.config.ts`, `eas.json` | The two app variants and their build profiles. |
|
||||
| `metro.config.js` | Keys Metro's cache by app variant (see Troubleshooting). |
|
||||
| `src/app/_layout.tsx` | Guards each app's screens with `Stack.Protected`. |
|
||||
| `src/app/driver.tsx` | The driver app: offline / waiting / on-a-ride, ride requests over it, current and queued ride. |
|
||||
| `src/components/DriverNavigation.tsx` | Full-screen turn-by-turn directions that keep running under anything shown on top. |
|
||||
| `src/lib/navigation.ts` | The simulated route to a pickup. |
|
||||
| `src/lib/location.ts` | The default location: the pickup fallback, and simulated phones' position. |
|
||||
| `src/components/RideMap.tsx` / `.web.tsx` | Native map, and a plain backdrop on web (`react-native-maps` has no web build). |
|
||||
| `public/sim.html` | The simulator page: phones as iframes plus the live dispatch panel. |
|
||||
| `cypress/support/e2e.ts` | Custom commands, network fixtures, and `defineScenario`. |
|
||||
| `cypress/support/phones.ts` | The shared cast: riders Alice and Bob; drivers Maria, James and Aisha. |
|
||||
| `cypress/support/phones.ts` | The shared cast: riders Alice, Bob and Carol; drivers Maria, James and Aisha. |
|
||||
| `cypress/scenarios/<name>.ts` | One scenario's steps and assertions. |
|
||||
| `cypress/e2e/<name>.cy.ts` | A thin spec that registers one scenario at normal speed. |
|
||||
| `cypress/e2e/slow/<name>.cy.ts` | The same scenario at watching speed. |
|
||||
|
|
@ -115,15 +159,18 @@ What each driver sees after a ride is decided:
|
|||
| `unmatched-at-price-cap` | Nobody takes the capped price: "No drivers available", then a fresh request starts over at round 1. |
|
||||
| `pricing-is-configurable` | A policy that starts above the quote with no cap is followed, and the rider sees "above your quote". |
|
||||
| `rider-cancels` | Cancelling withdraws the offer from every driver. |
|
||||
| `one-ride-per-driver` | A driver who takes one ride drops out of other auctions, including later rounds. |
|
||||
| `offline-drivers-get-no-requests` | Only drivers in driving mode are asked; starting mid-round joins it, going offline mid-round leaves it. |
|
||||
| `offer-interrupts-driving` | A request interrupts a waiting driver and a driver mid-ride; mid-ride, the directions keep running and accepting queues the ride; completing the trip moves on to it. |
|
||||
| `ride-queue-limit` | A driver can queue one ride behind the current one; when full they're skipped, and completing a trip lets them back in. |
|
||||
| `mocked-location` | A phone placed at Union Square uses that position for search, routing and its pickup, with no location prompt. |
|
||||
| `simultaneous-accepts` | Two drivers accept at once: whoever reached dispatch first wins, even if they tapped second. |
|
||||
|
||||
### Protocol
|
||||
|
||||
The server is the source of truth. After **every** change, it pushes a full snapshot to every connected device, so clients never have to replay missed events.
|
||||
|
||||
- Client → server: `hello`, `requestRide`, `cancelRide`, `ackRide`, `accept`, `decline`, `completeTrip`
|
||||
- Server → client: `snapshot` (the rider's current ride, or the driver's offers, job and last result) and `error` (a rejected action, shown on screen)
|
||||
- Client → server: `hello`, `requestRide`, `cancelRide`, `ackRide`, `accept`, `decline`, `completeTrip`, `setDriving`
|
||||
- Server → client: `snapshot` (the rider's current ride; or the driver's driving mode, offers, current and queued job, and last result) and `error` (a rejected action, shown on screen)
|
||||
|
||||
A rider's ride stays in their snapshot until they acknowledge it (`ackRide`: tapping **Done** on the trip screen or **Back** on "No drivers available").
|
||||
|
||||
|
|
@ -131,23 +178,29 @@ A rider's ride stays in their snapshot until they acknowledge it (`ackRide`: tap
|
|||
|
||||
```
|
||||
/sim.html?phones=<spec>,<spec>,...[&dispatch=http://host:8787]
|
||||
spec = role:id:name[:car[:plate[:rating]]]
|
||||
spec = role:id:name[:key=value...]
|
||||
```
|
||||
|
||||
- `role` is `rider` or `driver`. Riders open on `/`, drivers on `/driver`.
|
||||
- `role` is `rider` or `driver`: which app the phone runs. Riders open on `/`, drivers on `/driver`.
|
||||
- `id` must be unique. Cypress uses it to find the phone (`iframe[data-phone="<id>"]`), and dispatch uses it as the device id.
|
||||
- Each part may be URL-encoded, e.g. `driver:maria:Maria:Toyota%20Camry:7KDL294:4.95`.
|
||||
- Optional `key=value` parts, in any order:
|
||||
- `car`, `plate`, `rating`: how a driver appears to riders.
|
||||
- `lat`, `lng`: the phone's mocked location (see below).
|
||||
- `driving=0`: start a driver offline. Simulated drivers start in driving mode.
|
||||
- Each part may be URL-encoded, e.g. `driver:maria:Maria:car=Toyota%20Camry:plate=7KDL294:driving=0`.
|
||||
- `dispatch` defaults to the page's host on port 8787.
|
||||
|
||||
A phone reads its identity from its URL once, at startup, before the router rewrites the URL. Because every phone shares one browser origin, trip history and recent places are stored under a per-phone key prefix (`<id>:trips`).
|
||||
|
||||
**Location is mocked.** Simulated phones never call the real location API: there's nobody to answer a permission prompt, and the test machine's own position would leak into scenarios. Each phone sits at its `lat`/`lng`, or downtown San Francisco (37.7749, −122.4194) if none is given. That position is the rider's pickup, biases place search, and starts the route. A real device, or the web app opened outside the simulator (no `device` parameter), still uses real GPS. The logic is `simulatedLocation` in `src/lib/dispatch/device.ts`.
|
||||
|
||||
**Why iframes on the same origin:** Cypress can only reach into iframes that share the test page's origin. The simulator page is served from `public/` by the Expo dev server, the same origin as the app, for exactly this reason. Don't host it anywhere else.
|
||||
|
||||
## Dispatch HTTP API
|
||||
|
||||
| Method and path | Purpose |
|
||||
| ------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `GET /state` | The policy, devices (online/busy), and every ride with its rounds (price, who was offered it, who passed, who accepted). Used by the simulator panel and by assertions. |
|
||||
| `GET /state` | The policy, devices (online; drivers also `driving` and their `jobs`), and every ride with its pickup and rounds (price, who was offered it, who passed, who accepted). Used by the simulator panel and by assertions. |
|
||||
| `POST /test/reset` | Clears all rides and results and sets the policy: body `{ "policy": { "startMultiplier": 1.2, "maxMultiplier": null } }`. Fields left out take the server's defaults. Connected phones stay connected. |
|
||||
| `POST /test/rides/:id/end-round` | Ends the current round now, as if its time ran out. |
|
||||
|
||||
|
|
@ -201,11 +254,12 @@ Each scenario starts with `cy.resetDispatch()` and opens its own phones, so any
|
|||
| Command | Does |
|
||||
| ---------------------------------------------------- | --------------------------------------------------------------------- |
|
||||
| `cy.resetDispatch(policyOverrides?)` | `POST /test/reset` with `TEST_POLICY` plus any overrides. `roundDurationMs` is stretched on slow runs. |
|
||||
| `cy.openPhones(phones)` | Loads `/sim.html` with those phones and waits until each is on screen and connected to dispatch. |
|
||||
| `cy.openPhones(phones)` | Loads `/sim.html` with those phones and waits until each is on screen, connected, and (drivers) in its starting mode. A phone spec can set `location` and, for drivers, `driving: false`. |
|
||||
| `cy.phone(id)` | Yields that phone's `<body>`. Chain `.find(...)` / `.contains(...)` from it. |
|
||||
| `cy.requestRide(riderId, query, placeTitle)` | Searches, picks the place, taps **Choose UberX**, waits for "Finding your driver". |
|
||||
| `cy.accept(driverId, { rider? })` | Accepts the suggested fare. Pass `rider` (the rider's name) when the driver has several offers. |
|
||||
| `cy.pass(driverId, { rider? })` | Passes on the suggested fare. |
|
||||
| `cy.startDriving(id)` / `cy.goOffline(id)` | Taps **Start driving** / **Go offline** and waits until dispatch has registered it. |
|
||||
| `cy.pausePhone(id)` | Holds that phone's link to dispatch (see below). The simulator marks it with a dashed amber outline. |
|
||||
| `cy.resumePhone(id)` | Sends what the phone queued, in order, then delivers what it missed. |
|
||||
| `cy.dispatchState()` | `GET /state` once. |
|
||||
|
|
@ -236,9 +290,9 @@ The hook is `window.__dispatchLink` (`hold()`, `release()`, `held`), set on each
|
|||
| Screen | IDs |
|
||||
| ------------------- | --------------------------------------------------------------------------------------- |
|
||||
| Home / search | `where-to`, `destination-input`, `confirm-ride`, `price-<rideTypeId>`, `dispatch-error` |
|
||||
| Rider: finding | `ride-status`, `round-price`, `round-number`, `cancel-request`, `ride-fare`, `fare-vs-quote`, `matched-driver`, `driver-name`, `unmatched-back` |
|
||||
| Rider: finding | `ride-status`, `round-price`, `round-number`, `cancel-request`, `ride-fare`, `fare-vs-quote`, `matched-driver`, `driver-name`, `driver-finishing`, `unmatched-back` |
|
||||
| Rider: trip done | `ride-status` ("You've arrived"), `trip-done` |
|
||||
| Driver | `driver-status`, `driver-idle`, `driver-error`, `driver-result`, `offer`, `offer-round`, `offer-destination`, `offer-price`, `offer-passed`, `accept-offer`, `decline-offer`, `driver-job`, `job-fare`, `complete-trip` |
|
||||
| Driver | `driver-offline`, `start-driving`, `stop-driving`, `driver-waiting`, `driver-navigation`, `nav-heading`, `nav-instruction`, `next-job`, `driver-status` (`Connecting…` / `Offline` / `Driving`), `driver-error`, `driver-result`, `offer-sheet`, `offer`, `offer-round`, `offer-destination`, `offer-price`, `offer-passed`, `accept-offer`, `decline-offer`, `driver-job`, `job-fare`, `complete-trip` |
|
||||
|
||||
When you add UI that a scenario needs to touch, give it a `testID` rather than matching on text or styles.
|
||||
|
||||
|
|
@ -282,10 +336,11 @@ Use `typeDelayMs()` for any `.type()` a scenario does directly. The pace is set
|
|||
|
||||
Riders now get drivers only through dispatch, so run `npm run dispatch` alongside `npm start`. The app finds the server on the same host as the Metro dev server, port 8787. This works for simulators, emulators and physical devices on the same network. Override it with `EXPO_PUBLIC_DISPATCH_URL=ws://host:port`.
|
||||
|
||||
On a device, the app is always a rider (a random device id, named "You"). The driver role is only reachable through the simulator URL.
|
||||
Run the driver app with `npm run start:driver`. Outside the simulator, each install gets a random device id; riders are named "You" and drivers "Driver" until there are real accounts.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The rider app shows up when you started the driver app, or the reverse.** The app config is baked into Metro's transform cache. `metro.config.js` keys the cache by `APP_VARIANT` so this shouldn't happen; if it does (say, after changing `app.config.ts`), start once with `--clear`.
|
||||
- **Phones are blank in the simulator.** The web bundle failed to build. Open one phone's URL (e.g. `/?device=x`) directly to see the error. If you installed packages while a dev server was running, restart it with `npx expo start --clear`: a stale Metro file map can point at packages that have moved.
|
||||
- **"Can't reach dispatch" in the panel, or drivers stuck on "Connecting…".** The dispatch server isn't running, or something else holds port 8787.
|
||||
- **`npm run e2e` fails straight away with a port error.** Stop any `npm run dispatch` or `npm run sim:web` you started by hand; the e2e script starts its own.
|
||||
|
|
|
|||
34
eas.json
Normal file
34
eas.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"cli": {
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"env": { "APP_VARIANT": "rider" }
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"env": { "APP_VARIANT": "rider" }
|
||||
},
|
||||
"production": {
|
||||
"env": { "APP_VARIANT": "rider" }
|
||||
},
|
||||
"driver-development": {
|
||||
"extends": "development",
|
||||
"env": { "APP_VARIANT": "driver" }
|
||||
},
|
||||
"driver-preview": {
|
||||
"extends": "preview",
|
||||
"env": { "APP_VARIANT": "driver" }
|
||||
},
|
||||
"driver-production": {
|
||||
"extends": "production",
|
||||
"env": { "APP_VARIANT": "driver" }
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {}
|
||||
}
|
||||
}
|
||||
11
metro.config.js
Normal file
11
metro.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Learn more https://docs.expo.dev/guides/customizing-metro
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// The app config (app.config.ts) is baked into transformed modules, but Metro's cache
|
||||
// doesn't notice APP_VARIANT changing. Key the cache by variant so switching between the
|
||||
// rider and driver apps never serves the other app's config.
|
||||
config.cacheVersion = `${config.cacheVersion ?? ''}:app-variant=${process.env.APP_VARIANT === 'driver' ? 'driver' : 'rider'}`;
|
||||
|
||||
module.exports = config;
|
||||
|
|
@ -33,6 +33,7 @@
|
|||
},
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"start:driver": "APP_VARIANT=driver expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,12 @@
|
|||
|
||||
/sim.html?phones=rider:alice:Alice,driver:maria:Maria,driver:james:James
|
||||
|
||||
Phone spec: role:id:name[:car[:plate[:rating]]] (each part may be URL-encoded)
|
||||
Phone spec: role:id:name[:key=value...] (each part may be URL-encoded)
|
||||
car, plate, rating how the driver appears to riders
|
||||
lat, lng the phone's mocked location (phones never use real GPS;
|
||||
default downtown San Francisco)
|
||||
driving=0 start a driver offline (drivers start in driving mode)
|
||||
e.g. driver:maria:Maria:plate=7KDL294:driving=0
|
||||
Optional: &dispatch=http://host:8787 (defaults to this host, port 8787)
|
||||
-->
|
||||
<style>
|
||||
|
|
@ -97,14 +102,21 @@
|
|||
.split(',')
|
||||
.filter(Boolean)
|
||||
.map((spec) => {
|
||||
const [role, id, name, car, plate, rating] = spec.split(':').map(decodeURIComponent);
|
||||
return { role: role === 'driver' ? 'driver' : 'rider', id, name: name || id, car, plate, rating };
|
||||
const [role, id, name, ...extras] = spec.split(':').map(decodeURIComponent);
|
||||
const options = Object.fromEntries(
|
||||
extras.filter(Boolean).map((extra) => {
|
||||
const at = extra.indexOf('=');
|
||||
return at < 0 ? [extra, '1'] : [extra.slice(0, at), extra.slice(at + 1)];
|
||||
}),
|
||||
);
|
||||
return { role: role === 'driver' ? 'driver' : 'rider', id, name: name || id, options };
|
||||
});
|
||||
|
||||
const main = document.getElementById('phones');
|
||||
for (const spec of specs) {
|
||||
const query = new URLSearchParams({ device: spec.id, role: spec.role, name: spec.name });
|
||||
for (const key of ['car', 'plate', 'rating']) if (spec[key]) query.set(key, spec[key]);
|
||||
for (const key of ['car', 'plate', 'rating', 'lat', 'lng']) if (spec.options[key]) query.set(key, spec.options[key]);
|
||||
if (spec.role === 'driver') query.set('driving', spec.options.driving === '0' ? '0' : '1');
|
||||
const path = spec.role === 'driver' ? '/driver' : '/';
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
|
|
@ -139,7 +151,14 @@
|
|||
body.replaceChildren();
|
||||
for (const d of state.devices) {
|
||||
const row = el('div', 'device');
|
||||
row.append(el('span', '', `${d.name} (${d.role})`), el('span', '', d.online ? (d.busy ? 'on trip' : 'online') : 'offline'));
|
||||
const status = !d.online
|
||||
? 'disconnected'
|
||||
: d.role !== 'driver'
|
||||
? 'online'
|
||||
: [d.driving ? 'driving' : 'offline', d.jobs.length ? `${d.jobs.length} ride${d.jobs.length > 1 ? 's' : ''}` : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
row.append(el('span', '', `${d.name} (${d.role})`), el('span', '', status));
|
||||
body.append(row);
|
||||
}
|
||||
if (state.rides.length === 0) body.append(el('p', 'empty', 'No ride requests yet.'));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
// Development dispatch server: matches riders to drivers with a rising-price auction.
|
||||
//
|
||||
// When a rider requests a ride, every online, idle driver is offered the same suggested
|
||||
// fare and can accept or pass. The first driver to accept wins the ride at that price.
|
||||
// When a rider requests a ride, every online driver in driving mode with room for another
|
||||
// ride (a driver can have one ride in progress and one queued) is offered the same
|
||||
// suggested fare and can accept or pass. The first driver to accept wins the ride at that price.
|
||||
// If the round ends with no taker (everyone passed, or time ran out), a new round opens
|
||||
// at a higher price, until a driver accepts or the price cap is reached. Prices, step,
|
||||
// cap and round length come from the auction policy (see AuctionPolicy).
|
||||
|
|
@ -28,6 +29,9 @@ import type {
|
|||
|
||||
const PORT = Number(process.env.PORT ?? 8787);
|
||||
|
||||
/** The ride in progress plus one queued: a driver on a trip can line up their next ride. */
|
||||
const MAX_RIDES_PER_DRIVER = 2;
|
||||
|
||||
// Placeholder pricing until there's a real model: start at 80% of the quote and add 5%
|
||||
// of it per round, up to the quote itself, so the rider never pays more than quoted.
|
||||
const DEFAULT_POLICY: AuctionPolicy = {
|
||||
|
|
@ -59,13 +63,20 @@ type Ride = {
|
|||
rounds: Round[];
|
||||
winnerId: string | null;
|
||||
fare: number | null;
|
||||
/** When a driver accepted; orders a driver's rides into in progress, then queued. */
|
||||
matchedAt: number | null;
|
||||
completedAt: string | null;
|
||||
/** The rider has dismissed this ride; it no longer shows up in their snapshot. */
|
||||
acked: boolean;
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
};
|
||||
|
||||
type Device = { profile: DeviceProfile; sockets: Set<WebSocket> };
|
||||
type Device = {
|
||||
profile: DeviceProfile;
|
||||
sockets: Set<WebSocket>;
|
||||
/** Drivers only: in driving mode, i.e. taking ride requests. */
|
||||
driving: boolean;
|
||||
};
|
||||
|
||||
let policy: AuctionPolicy = { ...DEFAULT_POLICY };
|
||||
const devices = new Map<string, Device>();
|
||||
|
|
@ -106,12 +117,17 @@ function roundPrice(ride: Ride, number: number): { price: number; final: boolean
|
|||
|
||||
const isOnline = (id: string) => (devices.get(id)?.sockets.size ?? 0) > 0;
|
||||
|
||||
function currentJob(driverId: string): Ride | undefined {
|
||||
for (const ride of rides.values()) if (ride.status === 'matched' && ride.winnerId === driverId) return ride;
|
||||
return undefined;
|
||||
/** A driver's matched rides: the one in progress first, then the queued one. */
|
||||
function jobs(driverId: string): Ride[] {
|
||||
return [...rides.values()]
|
||||
.filter((ride) => ride.status === 'matched' && ride.winnerId === driverId)
|
||||
.sort((a, b) => a.matchedAt! - b.matchedAt!);
|
||||
}
|
||||
|
||||
const isBusy = (driverId: string) => currentJob(driverId) !== undefined;
|
||||
const isDriving = (driverId: string) => devices.get(driverId)?.driving ?? false;
|
||||
|
||||
/** Driving, with room for another ride. Doesn't care whether the phone is connected right now. */
|
||||
const canTakeRides = (driverId: string) => isDriving(driverId) && jobs(driverId).length < MAX_RIDES_PER_DRIVER;
|
||||
|
||||
const currentRound = (ride: Ride) => ride.rounds[ride.rounds.length - 1];
|
||||
|
||||
|
|
@ -125,7 +141,7 @@ function riderRide(riderId: string): Ride | undefined {
|
|||
|
||||
function availableDrivers(): string[] {
|
||||
return [...devices.values()]
|
||||
.filter((d) => d.profile.role === 'driver' && isOnline(d.profile.id) && !isBusy(d.profile.id))
|
||||
.filter((d) => d.profile.role === 'driver' && isOnline(d.profile.id) && canTakeRides(d.profile.id))
|
||||
.map((d) => d.profile.id);
|
||||
}
|
||||
|
||||
|
|
@ -155,6 +171,7 @@ function snapshotFor(profile: DeviceProfile): Snapshot {
|
|||
price: round.price,
|
||||
closesAt: round.closesAt,
|
||||
fare: ride.fare,
|
||||
driverFinishingTrip: ride.winnerId !== null && jobs(ride.winnerId)[0] !== ride && ride.status === 'matched',
|
||||
driver: driver && {
|
||||
name: driver.name,
|
||||
rating: driver.rating ?? 5,
|
||||
|
|
@ -166,8 +183,8 @@ function snapshotFor(profile: DeviceProfile): Snapshot {
|
|||
};
|
||||
}
|
||||
|
||||
const job = currentJob(profile.id);
|
||||
const offers = job
|
||||
const [job, nextJob] = jobs(profile.id);
|
||||
const offers = !canTakeRides(profile.id)
|
||||
? []
|
||||
: [...rides.values()]
|
||||
.filter((r) => r.status === 'auction' && currentRound(r).invited.includes(profile.id))
|
||||
|
|
@ -186,10 +203,14 @@ function snapshotFor(profile: DeviceProfile): Snapshot {
|
|||
declined: round.declined.has(profile.id),
|
||||
};
|
||||
});
|
||||
const jobView = (ride: Ride | undefined) =>
|
||||
ride ? { rideId: ride.id, riderName: riderName(ride), destination: ride.request.destination, fare: ride.fare! } : null;
|
||||
return {
|
||||
role: 'driver',
|
||||
driving: isDriving(profile.id),
|
||||
offers,
|
||||
job: job ? { rideId: job.id, riderName: riderName(job), destination: job.request.destination, fare: job.fare! } : null,
|
||||
job: jobView(job),
|
||||
nextJob: jobView(nextJob),
|
||||
lastResult: lastResults.get(profile.id) ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -231,6 +252,7 @@ function requestRide(riderId: string, request: RideRequest): Ride {
|
|||
rounds: [],
|
||||
winnerId: null,
|
||||
fare: null,
|
||||
matchedAt: null,
|
||||
completedAt: null,
|
||||
acked: false,
|
||||
timer: null,
|
||||
|
|
@ -261,17 +283,19 @@ function startRound(ride: Ride) {
|
|||
}, ride.policy.roundDurationMs);
|
||||
log(`${ride.id} round ${number}${final ? ' (final)' : ''} at $${price.toFixed(2)}, offered to [${round.invited.join(', ')}]`);
|
||||
// With nobody to ask, the round still runs its clock: a driver may come online meanwhile.
|
||||
if (round.invited.length > 0) endRoundIfEveryonePassed(ride);
|
||||
endRoundIfEveryonePassed(ride);
|
||||
}
|
||||
|
||||
/** Drivers in the current round who are still available and haven't passed. */
|
||||
/** Drivers in the current round who can still take the ride and haven't passed. */
|
||||
function undecidedDrivers(ride: Ride): string[] {
|
||||
const round = currentRound(ride);
|
||||
return round.invited.filter((id) => !round.declined.has(id) && !isBusy(id));
|
||||
return round.invited.filter((id) => !round.declined.has(id) && canTakeRides(id));
|
||||
}
|
||||
|
||||
function endRoundIfEveryonePassed(ride: Ride) {
|
||||
if (ride.status === 'auction' && undecidedDrivers(ride).length === 0) endRound(ride);
|
||||
if (ride.status !== 'auction') return;
|
||||
// A round nobody was offered just runs its clock; there's no one whose answer to await.
|
||||
if (currentRound(ride).invited.length > 0 && undecidedDrivers(ride).length === 0) endRound(ride);
|
||||
}
|
||||
|
||||
/** The current round ended with no taker: raise the price, or give up at the cap. */
|
||||
|
|
@ -298,14 +322,19 @@ function accept(ride: Ride, driverId: string) {
|
|||
ride.status = 'matched';
|
||||
ride.winnerId = driverId;
|
||||
ride.fare = round.price;
|
||||
ride.matchedAt = Date.now();
|
||||
lastResults.delete(driverId);
|
||||
for (const id of round.invited) {
|
||||
if (id !== driverId && !round.declined.has(id)) lastResults.set(id, { rideId: ride.id, outcome: 'lost' });
|
||||
}
|
||||
log(`${ride.id} accepted by ${driverId} at $${round.price.toFixed(2)} in round ${round.number}`);
|
||||
|
||||
// This driver is busy now, so rounds that were only waiting on them can move on.
|
||||
for (const other of rides.values()) if (other !== ride) endRoundIfEveryonePassed(other);
|
||||
// If that filled this driver up, rounds that were only waiting on them can move on.
|
||||
endOpenRoundsIfEveryonePassed();
|
||||
}
|
||||
|
||||
function endOpenRoundsIfEveryonePassed() {
|
||||
for (const ride of rides.values()) endRoundIfEveryonePassed(ride);
|
||||
}
|
||||
|
||||
/** The open round a driver is answering, checked against the round they saw. */
|
||||
|
|
@ -316,7 +345,8 @@ function roundToAnswer(rideId: string, roundNumber: number, driverId: string): R
|
|||
if (round.number !== roundNumber) throw new DispatchError('The price changed; check the new offer');
|
||||
if (!round.invited.includes(driverId)) throw new DispatchError('You were not offered this ride');
|
||||
if (round.declined.has(driverId)) throw new DispatchError('You already passed on this price');
|
||||
if (isBusy(driverId)) throw new DispatchError('Finish your current trip first');
|
||||
if (!isDriving(driverId)) throw new DispatchError('Start driving to take rides');
|
||||
if (!canTakeRides(driverId)) throw new DispatchError('You already have a ride queued');
|
||||
return ride;
|
||||
}
|
||||
|
||||
|
|
@ -367,20 +397,32 @@ function handle(device: Device, message: ClientMessage) {
|
|||
return;
|
||||
}
|
||||
case 'completeTrip': {
|
||||
const ride = rides.get(message.rideId);
|
||||
if (!ride || ride.status !== 'matched' || ride.winnerId !== id) throw new DispatchError('No such trip');
|
||||
ride.status = 'completed';
|
||||
ride.completedAt = new Date().toISOString();
|
||||
lastResults.set(id, { rideId: ride.id, outcome: 'completed', fare: ride.fare! });
|
||||
log(`${ride.id} completed by ${id}`);
|
||||
const [current] = jobs(id);
|
||||
if (!current || current.id !== message.rideId) throw new DispatchError('That isn’t your current trip');
|
||||
current.status = 'completed';
|
||||
current.completedAt = new Date().toISOString();
|
||||
lastResults.set(id, { rideId: current.id, outcome: 'completed', fare: current.fare! });
|
||||
log(`${current.id} completed by ${id}`);
|
||||
// Room for another ride again.
|
||||
inviteToOpenRounds(id);
|
||||
return;
|
||||
}
|
||||
case 'setDriving': {
|
||||
if (role !== 'driver') throw new DispatchError('Only drivers can drive');
|
||||
if (device.driving === message.driving) return;
|
||||
device.driving = message.driving;
|
||||
log(`${id} ${message.driving ? 'started' : 'stopped'} driving`);
|
||||
// Starting joins rounds already open; stopping may leave a round with nobody to wait on.
|
||||
if (message.driving) inviteToOpenRounds(id);
|
||||
else endOpenRoundsIfEveryonePassed();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A driver who comes online mid-round is offered that round too. */
|
||||
/** A driver who becomes available mid-round is offered that round too. */
|
||||
function inviteToOpenRounds(driverId: string) {
|
||||
if (isBusy(driverId)) return;
|
||||
if (!isOnline(driverId) || !canTakeRides(driverId)) return;
|
||||
for (const ride of rides.values()) {
|
||||
const round = ride.status === 'auction' ? currentRound(ride) : undefined;
|
||||
if (round && !round.invited.includes(driverId)) round.invited.push(driverId);
|
||||
|
|
@ -396,6 +438,8 @@ function reset(overrides: Partial<AuctionPolicy>) {
|
|||
for (const ride of rides.values()) if (ride.timer) clearTimeout(ride.timer);
|
||||
rides.clear();
|
||||
lastResults.clear();
|
||||
// Phones say whether they're driving when they connect; start everyone off the road.
|
||||
for (const device of devices.values()) device.driving = false;
|
||||
nextRideId = 1;
|
||||
policy = next;
|
||||
log(`reset, policy ${JSON.stringify(policy)}`);
|
||||
|
|
@ -407,7 +451,8 @@ function publicState() {
|
|||
devices: [...devices.values()].map(({ profile }) => ({
|
||||
...profile,
|
||||
online: isOnline(profile.id),
|
||||
busy: profile.role === 'driver' && isBusy(profile.id),
|
||||
driving: profile.role === 'driver' && isDriving(profile.id),
|
||||
jobs: profile.role === 'driver' ? jobs(profile.id).map((ride) => ride.id) : [],
|
||||
})),
|
||||
rides: [...rides.values()].map((r) => ({
|
||||
id: r.id,
|
||||
|
|
@ -415,6 +460,7 @@ function publicState() {
|
|||
status: r.status,
|
||||
destination: r.request.destination.title,
|
||||
quotedFare: r.request.quotedFare,
|
||||
pickup: r.request.pickup,
|
||||
rounds: r.rounds.map((round) => ({ ...round, declined: [...round.declined] })),
|
||||
winnerId: r.winnerId,
|
||||
fare: r.fare,
|
||||
|
|
@ -481,9 +527,10 @@ wss.on('connection', (socket) => {
|
|||
}
|
||||
|
||||
if (message.type === 'hello') {
|
||||
const sockets = devices.get(message.device.id)?.sockets ?? new Set<WebSocket>();
|
||||
const known = devices.get(message.device.id);
|
||||
const sockets = known?.sockets ?? new Set<WebSocket>();
|
||||
sockets.add(socket);
|
||||
device = { profile: message.device, sockets };
|
||||
device = { profile: message.device, sockets, driving: known?.driving ?? false };
|
||||
devices.set(message.device.id, device);
|
||||
if (message.device.role === 'driver') inviteToOpenRounds(message.device.id);
|
||||
log(`${message.device.role} ${message.device.id} connected`);
|
||||
|
|
|
|||
|
|
@ -1,21 +1,30 @@
|
|||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
|
||||
import { device } from '@/lib/dispatch/device';
|
||||
import { DispatchProvider } from '@/lib/dispatch/DispatchContext';
|
||||
import { RideProvider } from '@/lib/RideContext';
|
||||
|
||||
// The rider app and the driver app share this codebase; each only exposes its own screens.
|
||||
// Opening a guarded route (including "/" in the driver app) lands on the first allowed screen.
|
||||
const isDriverApp = device.role === 'driver';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<DispatchProvider>
|
||||
<RideProvider>
|
||||
<StatusBar style="dark" />
|
||||
<Stack screenOptions={{ headerTintColor: '#111', contentStyle: { backgroundColor: '#fff' } }}>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="search" options={{ presentation: 'modal', title: 'Where to?' }} />
|
||||
<Stack.Screen name="finding" options={{ headerShown: false, gestureEnabled: false, animation: 'fade' }} />
|
||||
<Stack.Screen name="trip" options={{ headerShown: false, gestureEnabled: false }} />
|
||||
<Stack.Screen name="trips" options={{ title: 'Your trips' }} />
|
||||
<Stack.Screen name="driver" options={{ headerShown: false }} />
|
||||
<Stack.Protected guard={!isDriverApp}>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="search" options={{ presentation: 'modal', title: 'Where to?' }} />
|
||||
<Stack.Screen name="finding" options={{ headerShown: false, gestureEnabled: false, animation: 'fade' }} />
|
||||
<Stack.Screen name="trip" options={{ headerShown: false, gestureEnabled: false }} />
|
||||
<Stack.Screen name="trips" options={{ title: 'Your trips' }} />
|
||||
</Stack.Protected>
|
||||
<Stack.Protected guard={isDriverApp}>
|
||||
<Stack.Screen name="driver" options={{ headerShown: false }} />
|
||||
</Stack.Protected>
|
||||
</Stack>
|
||||
</RideProvider>
|
||||
</DispatchProvider>
|
||||
|
|
|
|||
|
|
@ -1,58 +1,141 @@
|
|||
import LottieView from 'lottie-react-native';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { DriverNavigation } from '@/components/DriverNavigation';
|
||||
import { RideMap } from '@/components/RideMap';
|
||||
import { useDispatch } from '@/lib/dispatch/DispatchContext';
|
||||
import { device } from '@/lib/dispatch/device';
|
||||
import { device, startsDriving } from '@/lib/dispatch/device';
|
||||
import type { DriverJobView, DriverOfferView, DriverResult } from '@/lib/dispatch/protocol';
|
||||
import { useSecondsLeft } from '@/lib/dispatch/useSecondsLeft';
|
||||
import { pickupRoute } from '@/lib/navigation';
|
||||
import { useRide } from '@/lib/RideContext';
|
||||
import { formatDistance, formatDuration, formatPrice, getRideType } from '@/lib/rideTypes';
|
||||
|
||||
export default function DriverScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { connected, snapshot, error, send } = useDispatch();
|
||||
const { pickup: position } = useRide();
|
||||
const [driving, setDriving] = useState(startsDriving);
|
||||
|
||||
// Dispatch only sends requests to drivers in driving mode. Tell it on every (re)connect.
|
||||
useEffect(() => {
|
||||
if (connected) send({ type: 'setDriving', driving });
|
||||
}, [connected, driving, send]);
|
||||
|
||||
const driver = snapshot?.role === 'driver' ? snapshot : null;
|
||||
const job = driver?.job ?? null;
|
||||
const nextJob = driver?.nextJob ?? null;
|
||||
const offers = driver?.offers ?? [];
|
||||
const openOffers = offers.filter((offer) => !offer.declined);
|
||||
const passedOffers = offers.filter((offer) => offer.declined);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.screen}
|
||||
contentContainerStyle={[styles.content, { paddingTop: insets.top + 16, paddingBottom: insets.bottom + 24 }]}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{device.name}</Text>
|
||||
<View style={styles.statusRow}>
|
||||
<View style={[styles.dot, { backgroundColor: connected ? '#1a8f3c' : '#bbb' }]} />
|
||||
<Text style={styles.muted} testID="driver-status">
|
||||
{connected ? 'Online' : 'Connecting…'}
|
||||
<View style={styles.screen}>
|
||||
{/* On a ride: directions, which stay mounted under ride requests. Otherwise the map. */}
|
||||
{job ? (
|
||||
<DriverNavigation key={job.rideId} route={pickupRoute(job.riderName)} />
|
||||
) : (
|
||||
<RideMap pickup={position} userLocation={null} showsUserLocation={false} destination={null} route={null} topInset={insets.top} />
|
||||
)}
|
||||
|
||||
{!job && (driving ? <Waiting /> : <Offline onStart={() => setDriving(true)} />)}
|
||||
|
||||
<View style={[styles.sheet, { paddingBottom: insets.bottom + 16 }]}>
|
||||
{job && <Job job={job} onComplete={() => send({ type: 'completeTrip', rideId: job.rideId })} />}
|
||||
{nextJob && (
|
||||
<Text style={styles.next} testID="next-job">
|
||||
Next: {nextJob.riderName} → {nextJob.destination.title} · {formatPrice(nextJob.fare)}
|
||||
</Text>
|
||||
)}
|
||||
{!job &&
|
||||
passedOffers.map((offer) => (
|
||||
<Text key={offer.rideId} style={styles.passed} testID="offer-passed">
|
||||
You passed at {formatPrice(offer.price)} · {offer.riderName} → {offer.destination.title}
|
||||
</Text>
|
||||
))}
|
||||
{!job && driver?.lastResult && <ResultBanner result={driver.lastResult} />}
|
||||
{error && (
|
||||
<Text style={styles.error} testID="driver-error">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.statusRow}>
|
||||
<View style={[styles.dot, { backgroundColor: connected && driving ? '#1a8f3c' : '#bbb' }]} />
|
||||
<Text style={styles.name}>{device.name}</Text>
|
||||
<Text style={styles.muted} testID="driver-status">
|
||||
{!connected ? 'Connecting…' : driving ? 'Driving' : 'Offline'}
|
||||
</Text>
|
||||
{/* While a request is up, its sheet carries this button instead. */}
|
||||
{driving ? (
|
||||
openOffers.length === 0 && <GoOfflineButton onPress={() => setDriving(false)} />
|
||||
) : (
|
||||
job && (
|
||||
<Pressable style={styles.smallButton} onPress={() => setDriving(true)} accessibilityRole="button" testID="start-driving">
|
||||
<Text style={styles.smallButtonText}>Start driving</Text>
|
||||
</Pressable>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{error && (
|
||||
<Text style={styles.error} testID="driver-error">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{driver?.lastResult && !driver.job && <ResultBanner result={driver.lastResult} />}
|
||||
|
||||
{driver?.job ? (
|
||||
<Job job={driver.job} onComplete={() => send({ type: 'completeTrip', rideId: driver.job!.rideId })} />
|
||||
) : driver?.offers.length ? (
|
||||
driver.offers.map((offer) => (
|
||||
<Offer
|
||||
key={`${offer.rideId}:${offer.round}`}
|
||||
offer={offer}
|
||||
onAccept={() => send({ type: 'accept', rideId: offer.rideId, round: offer.round })}
|
||||
onPass={() => send({ type: 'decline', rideId: offer.rideId, round: offer.round })}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<View style={styles.idle} testID="driver-idle">
|
||||
<Text style={styles.idleTitle}>Waiting for ride requests</Text>
|
||||
<Text style={styles.muted}>You’ll be invited to bid when a rider nearby requests a ride.</Text>
|
||||
{openOffers.length > 0 && (
|
||||
<View style={styles.overlay} testID="offer-sheet">
|
||||
<ScrollView
|
||||
style={styles.offerSheet}
|
||||
contentContainerStyle={[styles.offerSheetContent, { paddingBottom: insets.bottom + 16 }]}
|
||||
>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Text style={styles.sheetTitle}>{openOffers.length === 1 ? 'New ride request' : `${openOffers.length} ride requests`}</Text>
|
||||
<GoOfflineButton onPress={() => setDriving(false)} />
|
||||
</View>
|
||||
{job && <Text style={styles.muted}>Accepting makes this your next ride, after {job.riderName}.</Text>}
|
||||
{openOffers.map((offer) => (
|
||||
<Offer
|
||||
key={`${offer.rideId}:${offer.round}`}
|
||||
offer={offer}
|
||||
onAccept={() => send({ type: 'accept', rideId: offer.rideId, round: offer.round })}
|
||||
onPass={() => send({ type: 'decline', rideId: offer.rideId, round: offer.round })}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function GoOfflineButton({ onPress }: { onPress: () => void }) {
|
||||
return (
|
||||
<Pressable style={styles.smallButton} onPress={onPress} accessibilityRole="button" testID="stop-driving">
|
||||
<Text style={styles.smallButtonText}>Go offline</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
/** Driving with no ride: waiting for requests. */
|
||||
function Waiting() {
|
||||
return (
|
||||
<View style={styles.center} testID="driver-waiting" pointerEvents="none">
|
||||
<LottieView source={require('../../assets/lottie/finding-driver.json')} autoPlay loop style={styles.animation} />
|
||||
<Text style={styles.centerTitle}>Looking for ride requests</Text>
|
||||
<Text style={styles.muted}>Requests pop up here while you’re driving.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Offline({ onStart }: { onStart: () => void }) {
|
||||
return (
|
||||
<View style={styles.center} testID="driver-offline">
|
||||
<View style={styles.offlineCard}>
|
||||
<Text style={styles.centerTitle}>You’re offline</Text>
|
||||
<Text style={styles.muted}>Start driving to get ride requests.</Text>
|
||||
<Pressable style={[styles.button, styles.primary, styles.startButton]} onPress={onStart} accessibilityRole="button" testID="start-driving">
|
||||
<Text style={styles.primaryText}>Start driving</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -77,20 +160,14 @@ function Offer({ offer, onAccept, onPass }: { offer: DriverOfferView; onAccept:
|
|||
{formatPrice(offer.price)}
|
||||
</Text>
|
||||
|
||||
{offer.declined ? (
|
||||
<Text style={styles.responded} testID="offer-passed">
|
||||
You passed at {formatPrice(offer.price)} · the price may go up if nobody takes it
|
||||
</Text>
|
||||
) : (
|
||||
<View style={styles.buttons}>
|
||||
<Pressable style={[styles.button, styles.secondary]} onPress={onPass} accessibilityRole="button" testID="decline-offer">
|
||||
<Text style={styles.secondaryText}>Pass</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.button, styles.primary]} onPress={onAccept} accessibilityRole="button" testID="accept-offer">
|
||||
<Text style={styles.primaryText}>Accept {formatPrice(offer.price)}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.buttons}>
|
||||
<Pressable style={[styles.button, styles.secondary]} onPress={onPass} accessibilityRole="button" testID="decline-offer">
|
||||
<Text style={styles.secondaryText}>Pass</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.button, styles.primary]} onPress={onAccept} accessibilityRole="button" testID="accept-offer">
|
||||
<Text style={styles.primaryText}>Accept {formatPrice(offer.price)}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -98,9 +175,9 @@ function Offer({ offer, onAccept, onPass }: { offer: DriverOfferView; onAccept:
|
|||
function Job({ job, onComplete }: { job: DriverJobView; onComplete: () => void }) {
|
||||
return (
|
||||
<View style={styles.card} testID="driver-job">
|
||||
<Text style={styles.won}>You won the ride</Text>
|
||||
<Text style={styles.won}>You got the ride</Text>
|
||||
<Text style={styles.destination}>
|
||||
Pick up {job.riderName} → {job.destination.title}
|
||||
{job.riderName} → {job.destination.title}
|
||||
</Text>
|
||||
<Text style={styles.fare} testID="job-fare">
|
||||
Fare {formatPrice(job.fare)}
|
||||
|
|
@ -129,24 +206,49 @@ function ResultBanner({ result }: { result: DriverResult }) {
|
|||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: '#fff' },
|
||||
content: { paddingHorizontal: 16, gap: 16 },
|
||||
header: { gap: 4 },
|
||||
title: { fontSize: 26, fontWeight: '700', color: '#111' },
|
||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
dot: { width: 8, height: 8, borderRadius: 4 },
|
||||
screen: { flex: 1, backgroundColor: '#e8ecef' },
|
||||
sheet: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#fff',
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 16,
|
||||
gap: 10,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 12,
|
||||
elevation: 12,
|
||||
},
|
||||
statusRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
dot: { width: 10, height: 10, borderRadius: 5 },
|
||||
name: { fontSize: 17, fontWeight: '700', color: '#111', flex: 1 },
|
||||
muted: { color: '#666', fontSize: 14 },
|
||||
error: { backgroundColor: '#fdecec', color: '#9b1c1c', padding: 10, borderRadius: 8, overflow: 'hidden' },
|
||||
banner: { backgroundColor: '#f1f1f1', color: '#333', padding: 12, borderRadius: 8, overflow: 'hidden' },
|
||||
idle: { alignItems: 'center', gap: 6, marginTop: 80, paddingHorizontal: 16 },
|
||||
idleTitle: { fontSize: 18, fontWeight: '600', color: '#111' },
|
||||
next: { backgroundColor: '#eef7f1', color: '#0b6e4f', padding: 10, borderRadius: 8, overflow: 'hidden', fontSize: 14, fontWeight: '600' },
|
||||
center: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 160, alignItems: 'center', justifyContent: 'center', gap: 6, paddingHorizontal: 24 },
|
||||
animation: { width: 220, height: 220 },
|
||||
centerTitle: { fontSize: 20, fontWeight: '700', color: '#111' },
|
||||
offlineCard: { backgroundColor: '#fff', borderRadius: 16, padding: 20, gap: 6, alignItems: 'center', alignSelf: 'stretch' },
|
||||
startButton: { flex: 0, alignSelf: 'stretch', marginTop: 10 },
|
||||
smallButton: { backgroundColor: '#eee', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 6 },
|
||||
smallButtonText: { fontSize: 14, fontWeight: '600', color: '#111' },
|
||||
passed: { backgroundColor: '#eef4fa', color: '#1a5f8f', padding: 10, borderRadius: 8, overflow: 'hidden', fontSize: 14 },
|
||||
overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.35)', justifyContent: 'flex-end' },
|
||||
offerSheet: { flexGrow: 0, maxHeight: '75%', backgroundColor: '#fff', borderTopLeftRadius: 20, borderTopRightRadius: 20 },
|
||||
offerSheetContent: { padding: 16, gap: 12 },
|
||||
sheetHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
sheetTitle: { fontSize: 20, fontWeight: '700', color: '#111' },
|
||||
card: { backgroundColor: '#f6f6f6', borderRadius: 14, padding: 16, gap: 8 },
|
||||
cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||
cardTitle: { fontSize: 13, fontWeight: '600', color: '#666', textTransform: 'uppercase' },
|
||||
destination: { fontSize: 18, fontWeight: '600', color: '#111' },
|
||||
price: { fontSize: 32, fontWeight: '700', color: '#111' },
|
||||
fare: { fontSize: 16, color: '#111' },
|
||||
responded: { fontSize: 15, color: '#1a5f8f', fontWeight: '500' },
|
||||
won: { fontSize: 13, fontWeight: '700', color: '#1a8f3c', textTransform: 'uppercase' },
|
||||
buttons: { flexDirection: 'row', gap: 8 },
|
||||
button: { flex: 1, borderRadius: 10, paddingVertical: 14, alignItems: 'center' },
|
||||
|
|
|
|||
|
|
@ -107,6 +107,11 @@ function Matched({ ride }: { ride: RiderRideView }) {
|
|||
</View>
|
||||
<Text style={styles.plate}>{driver.plate}</Text>
|
||||
</View>
|
||||
{ride.driverFinishingTrip && (
|
||||
<Text style={[styles.muted, styles.centered]} testID="driver-finishing">
|
||||
{driver.name} is finishing a nearby trip, then heading to you
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
82
src/components/DriverNavigation.tsx
Normal file
82
src/components/DriverNavigation.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { RideMap } from '@/components/RideMap';
|
||||
import { useRide } from '@/lib/RideContext';
|
||||
import type { NavRoute } from '@/lib/navigation';
|
||||
|
||||
// How long each simulated step lasts before the next maneuver comes up.
|
||||
const STEP_MS = 5000;
|
||||
|
||||
/**
|
||||
* Full-screen turn-by-turn navigation. It keeps advancing while other UI (like a ride
|
||||
* request) sits on top of it, and stops at the last step; give it a new `key` to start
|
||||
* a different route.
|
||||
*/
|
||||
export function DriverNavigation({ route }: { route: NavRoute }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { pickup: position } = useRide();
|
||||
const [index, setIndex] = useState(0);
|
||||
const last = route.steps.length - 1;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setIndex((i) => Math.min(i + 1, last)), STEP_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [last]);
|
||||
|
||||
const step = route.steps[index];
|
||||
const next = index < last ? route.steps[index + 1] : null;
|
||||
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill} testID="driver-navigation">
|
||||
<RideMap
|
||||
pickup={position}
|
||||
userLocation={null}
|
||||
showsUserLocation={false}
|
||||
destination={null}
|
||||
route={null}
|
||||
topInset={insets.top}
|
||||
/>
|
||||
<View style={[styles.banner, { paddingTop: insets.top + 12 }]}>
|
||||
<Text style={styles.heading} testID="nav-heading">
|
||||
{route.heading}
|
||||
</Text>
|
||||
<View style={styles.stepRow}>
|
||||
<Text style={styles.maneuver}>{step.maneuver}</Text>
|
||||
<View style={styles.stepText}>
|
||||
<Text style={styles.distance}>{step.distance}</Text>
|
||||
<Text style={styles.instruction} testID="nav-instruction">
|
||||
{step.instruction}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{next && (
|
||||
<Text style={styles.then}>
|
||||
Then {next.maneuver} {next.instruction}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
banner: { backgroundColor: '#0b6e4f', paddingHorizontal: 16, paddingBottom: 14, gap: 6 },
|
||||
heading: { color: '#bfe6d6', fontSize: 13, fontWeight: '600', textTransform: 'uppercase' },
|
||||
stepRow: { flexDirection: 'row', alignItems: 'center', gap: 14 },
|
||||
maneuver: { color: '#fff', fontSize: 40, fontWeight: '700', width: 44, textAlign: 'center' },
|
||||
stepText: { flex: 1 },
|
||||
distance: { color: '#fff', fontSize: 26, fontWeight: '700' },
|
||||
instruction: { color: '#fff', fontSize: 18, fontWeight: '500' },
|
||||
then: {
|
||||
alignSelf: 'flex-start',
|
||||
backgroundColor: '#095a41',
|
||||
color: '#e6f4ee',
|
||||
fontSize: 14,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderBottomRightRadius: 10,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,16 +1,15 @@
|
|||
import * as Location from 'expo-location';
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
|
||||
import { simulatedLocation } from './dispatch/device';
|
||||
import { useDispatch } from './dispatch/DispatchContext';
|
||||
import type { RiderRideView } from './dispatch/protocol';
|
||||
import { DEFAULT_LOCATION } from './location';
|
||||
import { getRideType, priceFor, RIDE_TYPES } from './rideTypes';
|
||||
import { fetchRoute } from './route';
|
||||
import { saveTrip } from './storage';
|
||||
import type { Coord, Place, Route, Trip } from './types';
|
||||
|
||||
// Used as the pickup point when location permission is denied or still pending.
|
||||
export const DEFAULT_LOCATION: Coord = { latitude: 37.7749, longitude: -122.4194 };
|
||||
|
||||
type LocationStatus = 'pending' | 'granted' | 'denied';
|
||||
|
||||
type RideState = {
|
||||
|
|
@ -41,8 +40,8 @@ type RideState = {
|
|||
const RideContext = createContext<RideState | null>(null);
|
||||
|
||||
export function RideProvider({ children }: { children: ReactNode }) {
|
||||
const [userLocation, setUserLocation] = useState<Coord | null>(null);
|
||||
const [locationStatus, setLocationStatus] = useState<LocationStatus>('pending');
|
||||
const [userLocation, setUserLocation] = useState<Coord | null>(simulatedLocation);
|
||||
const [locationStatus, setLocationStatus] = useState<LocationStatus>(simulatedLocation ? 'granted' : 'pending');
|
||||
const [destination, setDestination] = useState<Place | null>(null);
|
||||
const [route, setRoute] = useState<Route | null>(null);
|
||||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
|
|
@ -57,6 +56,8 @@ export function RideProvider({ children }: { children: ReactNode }) {
|
|||
const routeRequest = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Simulated phones keep the mocked position they started with.
|
||||
if (simulatedLocation) return;
|
||||
let subscription: Location.LocationSubscription | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import Constants from 'expo-constants';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import { DEFAULT_LOCATION } from '../location';
|
||||
import type { Coord } from '../types';
|
||||
import type { DeviceProfile, Role } from './protocol';
|
||||
|
||||
const DISPATCH_PORT = 8787;
|
||||
|
|
@ -10,11 +12,13 @@ function randomId(prefix: string): string {
|
|||
}
|
||||
|
||||
// On web, a phone in the multi-phone simulator is configured by its URL, e.g.
|
||||
// /driver?device=maria&role=driver&name=Maria&car=Toyota%20Prius&plate=7KDL294
|
||||
// /driver?device=maria&role=driver&name=Maria&car=Toyota%20Prius&plate=7KDL294&lat=37.78&lng=-122.41
|
||||
// Read once at startup, before the router starts rewriting the URL.
|
||||
const params =
|
||||
Platform.OS === 'web' && typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
||||
|
||||
function profileFromUrl(): DeviceProfile | null {
|
||||
if (Platform.OS !== 'web' || typeof window === 'undefined') return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!params) return null;
|
||||
const id = params.get('device');
|
||||
if (!id) return null;
|
||||
const role: Role = params.get('role') === 'driver' ? 'driver' : 'rider';
|
||||
|
|
@ -31,7 +35,32 @@ function profileFromUrl(): DeviceProfile | null {
|
|||
|
||||
const fromUrl = profileFromUrl();
|
||||
|
||||
export const device: DeviceProfile = fromUrl ?? { id: randomId('rider'), role: 'rider', name: 'You' };
|
||||
/** Which app this build is (app.config.ts, from APP_VARIANT): the rider app or the driver app. */
|
||||
export const appVariant: Role = Constants.expoConfig?.extra?.appVariant === 'driver' ? 'driver' : 'rider';
|
||||
|
||||
/** Who this phone is. In the simulator each phone's URL says; otherwise it follows the app variant. */
|
||||
export const device: DeviceProfile = fromUrl ?? {
|
||||
id: randomId(appVariant),
|
||||
role: appVariant,
|
||||
name: appVariant === 'driver' ? 'Driver' : 'You',
|
||||
};
|
||||
|
||||
/** Simulated drivers can start out in driving mode (`driving=1`); real drivers start offline. */
|
||||
export const startsDriving = fromUrl?.role === 'driver' && params?.get('driving') === '1';
|
||||
|
||||
/**
|
||||
* Simulated phones never use the real location API: there's nobody to answer a permission
|
||||
* prompt, and the test machine's position would leak into scenarios. They sit at the
|
||||
* position in their URL (`lat`, `lng`), or downtown San Francisco. Null outside the simulator.
|
||||
*/
|
||||
export const simulatedLocation: Coord | null = fromUrl ? locationFromUrl() : null;
|
||||
|
||||
function locationFromUrl(): Coord {
|
||||
const latitude = Number(params?.get('lat'));
|
||||
const longitude = Number(params?.get('lng'));
|
||||
const valid = params?.has('lat') && params.has('lng') && Number.isFinite(latitude) && Number.isFinite(longitude);
|
||||
return valid ? { latitude, longitude } : DEFAULT_LOCATION;
|
||||
}
|
||||
|
||||
/** Simulated phones share one browser origin, so each one namespaces its local storage. */
|
||||
export const storagePrefix = fromUrl ? `${fromUrl.id}:` : '';
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ export type RiderRideView = {
|
|||
closesAt: number;
|
||||
fare: number | null;
|
||||
driver: Driver | null;
|
||||
/** The matched driver is still on an earlier trip and will come after it. */
|
||||
driverFinishingTrip: boolean;
|
||||
completedAt: string | null;
|
||||
};
|
||||
|
||||
|
|
@ -94,7 +96,17 @@ export type DriverResult =
|
|||
|
||||
export type Snapshot =
|
||||
| { role: 'rider'; ride: RiderRideView | null }
|
||||
| { role: 'driver'; offers: DriverOfferView[]; job: DriverJobView | null; lastResult: DriverResult | null };
|
||||
| {
|
||||
role: 'driver';
|
||||
/** In driving mode, i.e. taking ride requests. */
|
||||
driving: boolean;
|
||||
offers: DriverOfferView[];
|
||||
/** The ride in progress. */
|
||||
job: DriverJobView | null;
|
||||
/** A ride accepted while on `job`, to start once it's done. */
|
||||
nextJob: DriverJobView | null;
|
||||
lastResult: DriverResult | null;
|
||||
};
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: 'hello'; device: DeviceProfile }
|
||||
|
|
@ -104,6 +116,8 @@ export type ClientMessage =
|
|||
| { type: 'ackRide' }
|
||||
| { type: 'accept'; rideId: string; round: number }
|
||||
| { type: 'decline'; rideId: string; round: number }
|
||||
| { type: 'completeTrip'; rideId: string };
|
||||
| { type: 'completeTrip'; rideId: string }
|
||||
/** Driver starts or stops taking ride requests. Sent on every (re)connect, too. */
|
||||
| { type: 'setDriving'; driving: boolean };
|
||||
|
||||
export type ServerMessage = { type: 'snapshot'; snapshot: Snapshot } | { type: 'error'; message: string };
|
||||
|
|
|
|||
5
src/lib/location.ts
Normal file
5
src/lib/location.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { Coord } from './types';
|
||||
|
||||
// Used as the pickup point when location permission is denied or still pending, and as
|
||||
// simulated phones' position when their URL doesn't give one.
|
||||
export const DEFAULT_LOCATION: Coord = { latitude: 37.7749, longitude: -122.4194 };
|
||||
28
src/lib/navigation.ts
Normal file
28
src/lib/navigation.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Simulated turn-by-turn directions for the driver app. There's no real routing or GPS
|
||||
// behind these yet; they give a driver on a ride a believable navigation screen.
|
||||
|
||||
export type NavStep = {
|
||||
/** Arrow glyph for the maneuver. */
|
||||
maneuver: string;
|
||||
distance: string;
|
||||
instruction: string;
|
||||
};
|
||||
|
||||
export type NavRoute = {
|
||||
/** Shown above the directions, e.g. where the driver is heading. */
|
||||
heading: string;
|
||||
steps: NavStep[];
|
||||
};
|
||||
|
||||
export function pickupRoute(riderName: string): NavRoute {
|
||||
return {
|
||||
heading: `Pick up ${riderName}`,
|
||||
steps: [
|
||||
{ maneuver: '↑', distance: '400 m', instruction: 'Continue on Market St' },
|
||||
{ maneuver: '↱', distance: '200 m', instruction: 'Turn right onto 4th St' },
|
||||
{ maneuver: '↰', distance: '150 m', instruction: 'Turn left onto Folsom St' },
|
||||
{ maneuver: '↱', distance: '300 m', instruction: 'Turn right onto Spear St' },
|
||||
{ maneuver: '◉', distance: '50 m', instruction: `${riderName} is on the right` },
|
||||
],
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue