Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/rides.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@ name: Rides demo
on:
push:
branches: [main]
paths: ['apps/rides/**', 'services/ride-demo/**', '.github/workflows/rides.yml']
paths: ['apps/rides/**', 'services/ride-demo/**', 'services/ride-convex/**', '.github/workflows/rides.yml']
pull_request:
paths: ['apps/rides/**', 'services/ride-demo/**', '.github/workflows/rides.yml']
paths: ['apps/rides/**', 'services/ride-demo/**', 'services/ride-convex/**', '.github/workflows/rides.yml']
jobs:
convex:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
- run: npm ci && npm run typecheck && npm test
working-directory: services/ride-convex
server:
runs-on: ubuntu-latest
steps:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# EatsClone

This repository also contains a separate native **Rides** app in [`apps/rides`](apps/rides) and its shared demo service in [`services/ride-demo`](services/ride-demo). Eats remains unchanged. See the [rider/driver two-phone demo guide](docs/RIDES.md) for builds, pairing, references, and limitations.
This repository also contains a separate native **Rides** app in [`apps/rides`](apps/rides), its hosted [Convex backend](services/ride-convex), and an optional legacy [HTTP service](services/ride-demo). Eats remains unchanged. See the [rider/driver two-phone demo guide](docs/RIDES.md) for builds, pairing, references, and limitations.

An independent, self-contained food delivery demo built with native SwiftUI for iOS 17 and later. Its interaction design is inspired by the Uber Eats references on Mobbin. This project is not affiliated with, endorsed by, or connected to Uber, Uber Eats, Mobbin, Unsplash, or any restaurant shown in its sample catalog.

Expand Down
8 changes: 5 additions & 3 deletions apps/rides/.revyl/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ pr_review:
paths:
- apps/rides/**
- services/ride-demo/**
- services/ride-convex/**
- docs/RIDES.md
- docs/RIDES_VERIFICATION.md
- .github/workflows/rides.yml
Expand All @@ -40,8 +41,9 @@ pr_review:
always_verify:
- "Scope this proof to the Rides application selected by this project's rides profile. Verify all applicable Rides changes and the checks below, and report any observed Rides defect. Eats has its own root project, build, session, and proof at .revyl/config.yaml. Do not require Eats ordering screens inside the Rides binary; they belong to the separate Eats proof, not an unmet Rides verification requirement."
- A fresh Rides session opens Home with the Revyl sign-in accepted Rider banner, without opening Eats or asking for real authentication.
- Open Where to, select an offline destination, choose a vehicle and payment label, and reach Confirm your pickup. Without a configured demo server, Connect to request a ride opens Demo connection rather than inventing a driver, trip, or charge. Do not enter real payment information.
- Dismiss the connection and booking sheets, then verify Home, Services, Activity, and Account remain reachable. Open saved places and wallet from Account using only demo data.
- Two-phone dispatch, chat, completion, and cancellation require a shared reachable HTTPS service and a second phone. Do not report those behaviors as passed from this single-phone proof.
- The hosted Convex backend is prefilled in Demo connection. Create a new private Rider room, then Continue as Rider. Keep invitations hidden and never include invitation codes, session tokens, or credentials in public evidence.
- Open Where to, select an offline destination, choose a vehicle and demo payment label, and confirm pickup. Verify the hosted request reaches Finding your ride; without a second online driver it must not invent an acceptance or charge. Cancel the requested trip and verify a zero-charge Cancelled history entry. Do not enter real payment information.
- Home, Services, Activity, and Account remain reachable after the cancellation. Open saved places and wallet from Account using only demo data.
- Driver acceptance, two-way chat, arrival, start, completion, receipts, rating, and tips require a second Rides phone joined to the same private room. Do not report those paired behaviors as passed from this single-phone proof.
strict_ci_check:
build: true
4 changes: 2 additions & 2 deletions apps/rides/App/RidesDemoApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ struct RideRootView: View {
}
.sheet(isPresented: $store.showConnection) { DemoConnectionView().environmentObject(store) }
.fullScreenCover(isPresented: $store.showBooking) { BookingFlowView().environmentObject(store) }
.task(id: scenePhase) {
if scenePhase == .active { await store.runPolling() }
.task(id: scenePhase == .active ? store.credentials?.token : nil) {
if scenePhase == .active { await store.runUpdates() }
}
}
}
92 changes: 92 additions & 0 deletions apps/rides/Core/ConvexRideAPI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import Combine
import ConvexMobile
import Foundation
import Security

extension TripRequest: ConvexEncodable {}
extension RideAction: ConvexEncodable {}

final class ConvexRideAPIClient: RideAPI {
let baseURL: URL
private let client: ConvexClient
private let connectionState = CurrentValueSubject<Bool, Never>(false)
private var socketObservation: AnyCancellable?

init(baseURL: URL) {
self.baseURL = baseURL
client = ConvexClient(deploymentUrl: baseURL.absoluteString)
socketObservation = client.watchWebSocketState().sink { [connectionState] in connectionState.send($0 == .connected) }
}

static func freshToken() throws -> String {
var bytes = [UInt8](repeating: 0, count: 32)
guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else {
throw RideAPIError(status: 0, message: "Couldn't create a secure session. Try again.")
}
return Data(bytes).base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
}

func create(name: String) async throws -> RideSession {
try await mutate("createRoom", args: ["name": name, "token": Self.freshToken()])
}

func join(code: String, role: DemoRole, name: String) async throws -> RideSession {
try await mutate("joinRoom", args: ["code": code, "role": role.rawValue, "name": name, "token": Self.freshToken()])
}

func state(token: String) async throws -> RideRoomState {
do {
for try await state in client.subscribe(to: "rides:state", with: ["token": token], yielding: RideRoomState.self).values {
return state
}
throw RideAPIError(status: 0, message: "The shared ride connection closed. Try again.")
} catch { throw Self.apiError(error) }
}

func availability(token: String, online: Bool) async throws -> RideRoomState {
try await mutate("availability", args: ["token": token, "online": online])
}

func book(token: String, request: TripRequest) async throws -> RideRoomState {
try await mutate("book", args: ["token": token, "request": request])
}

func act(token: String, tripID: String, action: RideAction) async throws -> RideRoomState {
try await mutate("act", args: ["token": token, "tripID": tripID, "action": action])
}

func updates(token: String) -> AsyncThrowingStream<RideSyncEvent, Error> {
AsyncThrowingStream { continuation in
let state = client.subscribe(to: "rides:state", with: ["token": token], yielding: RideRoomState.self)
.map(RideSyncEvent.state)
let connection = connectionState
.map(RideSyncEvent.connection)
.setFailureType(to: ClientError.self)
let subscription = state.merge(with: connection).sink { completion in
switch completion {
case .finished: continuation.finish()
case .failure(let error): continuation.finish(throwing: Self.apiError(error))
}
} receiveValue: { event in
continuation.yield(event)
}
continuation.onTermination = { _ in subscription.cancel() }
}
}

private func mutate<T: Decodable>(_ name: String, args: [String: ConvexEncodable?]) async throws -> T {
do { return try await client.mutation("rides:" + name, with: args) }
catch { throw Self.apiError(error) }
}

static func apiError(_ error: Error) -> RideAPIError {
if let error = error as? RideAPIError { return error }
struct Failure: Decodable { let status: Int; let message: String }
if let error = error as? ClientError, case .ConvexError(let data) = error,
let failure = try? JSONDecoder().decode(Failure.self, from: Data(data.utf8)),
(400..<500).contains(failure.status) {
return RideAPIError(status: failure.status, message: failure.message)
}
return RideAPIError(status: 0, message: "Couldn't reach the shared ride backend. Check your connection and try again.")
}
}
38 changes: 38 additions & 0 deletions apps/rides/Core/RideAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ struct RideAPIError: LocalizedError, Equatable {
var errorDescription: String? { message }
}

enum RideSyncEvent {
case state(RideRoomState)
case connection(Bool)
}

protocol RideAPI {
var baseURL: URL { get }
func create(name: String) async throws -> RideSession
Expand All @@ -17,6 +22,39 @@ protocol RideAPI {
func availability(token: String, online: Bool) async throws -> RideRoomState
func book(token: String, request: TripRequest) async throws -> RideRoomState
func act(token: String, tripID: String, action: RideAction) async throws -> RideRoomState
func updates(token: String) -> AsyncThrowingStream<RideSyncEvent, Error>
}

extension RideAPI {
func updates(token: String) -> AsyncThrowingStream<RideSyncEvent, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
while !Task.isCancelled {
let incoming = try await state(token: token)
continuation.yield(.connection(true))
continuation.yield(.state(incoming))
try await Task.sleep(for: .seconds(1))
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
}

enum RideBackend {
static var defaultAddress: String {
Bundle.main.object(forInfoDictionaryKey: "ConvexDeploymentURL") as? String ?? ""
}

static func client(for url: URL) -> any RideAPI {
if url.host?.hasSuffix(".convex.cloud") == true { return ConvexRideAPIClient(baseURL: url) }
return RideAPIClient(baseURL: url)
}
}

struct RideAPIClient: RideAPI {
Expand Down
66 changes: 47 additions & 19 deletions apps/rides/Core/RideStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ final class RideStore: ObservableObject {
@Published private(set) var credentials: StoredRideSession?
@Published private(set) var state: RideRoomState?
@Published private(set) var lastSync: Date?
@Published private(set) var syncConnected = false
@Published private(set) var busy = false
@Published var errorMessage: String?
@Published var notice: String?
Expand Down Expand Up @@ -67,15 +68,15 @@ final class RideStore: ObservableObject {
private let apiFactory: (URL) -> any RideAPI
private var api: (any RideAPI)?

init(defaults: UserDefaults = .standard, vault: any RideSessionVault = KeychainRideSessionVault(), apiFactory: @escaping (URL) -> any RideAPI = { RideAPIClient(baseURL: $0) }) {
init(defaults: UserDefaults = .standard, vault: any RideSessionVault = KeychainRideSessionVault(), apiFactory: @escaping (URL) -> any RideAPI = { RideBackend.client(for: $0) }) {
self.defaults = defaults
self.vault = vault
self.apiFactory = apiFactory
let restored = vault.load()
credentials = restored
role = restored?.role ?? .rider
displayName = restored?.name ?? DemoRole.rider.defaultName
serverAddress = restored?.baseURL.absoluteString ?? RevylRideSignIn.launchValue("RIDE_DEMO_BASE_URL") ?? defaults.string(forKey: "serverAddress") ?? ""
serverAddress = restored?.baseURL.absoluteString ?? RevylRideSignIn.launchValue("RIDE_DEMO_BASE_URL") ?? defaults.string(forKey: "serverAddress") ?? RideBackend.defaultAddress
savedPlaces = defaults.data(forKey: "savedPlaces").flatMap { try? JSONDecoder().decode([DemoPlace].self, from: $0) } ?? [RideCatalog.places[2], RideCatalog.places[3]]
rideReminders = defaults.object(forKey: "rideReminders") as? Bool ?? true
defaultPayment = defaults.string(forKey: "defaultPayment").flatMap(DemoPayment.init(rawValue:)) ?? .visa
Expand All @@ -91,7 +92,7 @@ final class RideStore: ObservableObject {
var earningsCents: Int {
(state?.trips ?? []).filter { $0.status == .completed && $0.driver?.id == credentials?.participantId }.reduce(0) { $0 + $1.totalCents }
}
var syncIsFresh: Bool { lastSync.map { Date().timeIntervalSince($0) < 5 } ?? false }
var syncIsFresh: Bool { syncConnected }

func chooseRole(_ value: DemoRole) {
guard !connected else { return }
Expand Down Expand Up @@ -133,14 +134,30 @@ final class RideStore: ObservableObject {
api = nil
state = nil
lastSync = nil
syncConnected = false
focusedTripID = nil
roomInput = ""
vault.clear()
}

func runPolling() async {
while !Task.isCancelled {
if connected { await sync() }
func runUpdates() async {
guard let credentials, let api else { return }
defer { if self.credentials?.token == credentials.token { syncConnected = false } }
while !Task.isCancelled, self.credentials?.token == credentials.token {
var connectionLive = false
do {
for try await event in api.updates(token: credentials.token) {
guard !Task.isCancelled, self.credentials?.token == credentials.token else { return }
switch event {
case .state(let state): apply(state, for: credentials, live: connectionLive)
case .connection(let live):
connectionLive = live
syncConnected = live && state != nil
if live && errorMessage?.hasPrefix("Connection lost.") == true { errorMessage = nil }
}
}
} catch { handleSyncError(error, for: credentials) }
guard self.credentials?.token == credentials.token else { return }
do { try await Task.sleep(for: .seconds(1)) } catch { return }
}
}
Expand All @@ -152,26 +169,33 @@ final class RideStore: ObservableObject {
guard !Task.isCancelled else { return }
apply(state, for: credentials)
} catch {
guard !Task.isCancelled, self.credentials?.token == credentials.token else { return }
if let error = error as? RideAPIError, [401, 404].contains(error.status) {
self.credentials = nil
self.api = nil
state = nil
lastSync = nil
vault.clear()
showConnection = true
errorMessage = "This demo session has expired. Create or join a room again."
} else {
errorMessage = "Connection lost. Check the demo server and tap Retry."
}
handleSyncError(error, for: credentials)
}
}

private func handleSyncError(_ error: Error, for credentials: StoredRideSession) {
guard !Task.isCancelled, self.credentials?.token == credentials.token else { return }
syncConnected = false
if let error = error as? RideAPIError, [401, 404].contains(error.status) {
self.credentials = nil
api = nil
state = nil
lastSync = nil
vault.clear()
showConnection = true
errorMessage = "This demo session has expired. Create or join a room again."
} else {
errorMessage = "Connection lost. Check your internet connection and tap Retry."
}
}

private func apply(_ incoming: RideRoomState, for credentials: StoredRideSession) {
private func apply(_ incoming: RideRoomState, for credentials: StoredRideSession, live: Bool = true) {
guard self.credentials?.token == credentials.token, incoming.roomCode == credentials.roomCode else { return }
guard state == nil || incoming.revision >= state!.revision else { return }
state = incoming
if credentials.role == .rider, let active = incoming.activeTrip { focusedTripID = active.id }
lastSync = Date()
syncConnected = live
if errorMessage?.hasPrefix("Connection lost.") == true { errorMessage = nil }
}

Expand All @@ -186,6 +210,10 @@ final class RideStore: ObservableObject {
errorMessage = nil
return true
} catch {
if (error as? RideAPIError)?.status == 401 {
handleSyncError(error, for: credentials)
return false
}
if (error as? RideAPIError)?.status == 409 { await sync() }
errorMessage = error.localizedDescription
return false
Expand Down
Loading
Loading