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
10 changes: 10 additions & 0 deletions .capyignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,13 @@ docs
.git
.build
.swiftpm
.generated
.revyl/before-session-values.json
.revyl/dev-sessions.json
.revyl/device-session.json
.revyl/device-history.json
*/.revyl/before-session-values.json
services/ride-demo/.data
apps/rides/build
apps/rides/.build
apps/rides/.generated
19 changes: 19 additions & 0 deletions .github/workflows/revyl.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Revyl setup
on:
push:
branches: [main]
paths: ['.revyl/**', 'apps/rides/.revyl/**', 'scripts/prepare-revyl-session.py', 'apps/rides/scripts/*.py', 'scripts/test_revyl_config.py', '.github/workflows/revyl.yml']
pull_request:
paths: ['.revyl/**', 'apps/rides/.revyl/**', 'scripts/prepare-revyl-session.py', 'apps/rides/scripts/*.py', 'scripts/test_revyl_config.py', '.github/workflows/revyl.yml']
permissions:
contents: read
jobs:
isolation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: python -m pip install PyYAML==6.0.3
- run: python scripts/test_revyl_config.py
23 changes: 23 additions & 0 deletions .github/workflows/rides.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Rides demo
on:
push:
branches: [main]
paths: ['apps/rides/**', 'services/ride-demo/**', '.github/workflows/rides.yml']
pull_request:
paths: ['apps/rides/**', 'services/ride-demo/**', '.github/workflows/rides.yml']
jobs:
server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
- run: npm test
working-directory: services/ride-demo
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- run: bash scripts/verify-ios.sh
working-directory: apps/rides
10 changes: 10 additions & 0 deletions .revyl/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ build:
pr_review:
enabled: true
review_triggers:
paths:
- EatsClone/**
- Resources/**
- scripts/**
- project.yml
- Package.swift
- .revyl/**
- .github/workflows/ios.yml
- .github/workflows/revyl.yml
drafts: false
build:
kind: revyl
Expand All @@ -30,6 +39,7 @@ pr_review:
harness:
kind: revyl
always_verify:
- "Scope this proof to the Eats application selected by this project's pull-request profile. Verify all applicable Eats changes and the regression checks below, and report any observed Eats defect. Rides has its own project root, build, session, and proof at apps/rides/.revyl/config.yaml. Do not require Rides screens, its driver role, or its service inside the Eats binary; those belong to the separate Rides proof, not an unmet Eats verification requirement."
- The fresh session bypasses onboarding and shows the Home tab with the Revyl sign-in accepted banner.
- Add a meal, customize it, open the cart, and place a demo order without real authentication, network services, or payment.
- The Home, Browse, Orders, and Account tabs remain reachable when the cart contains items.
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# 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.

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.

The initial release focuses on a local restaurant-ordering journey: onboarding, discovery, menus, item customization, cart, demo checkout, simulated order progress, and saved account data. This is not yet a complete reproduction of Uber Eats. See [scope and limitations](docs/PARITY.md), the [parity roadmap](docs/ROADMAP.md), and [design references](docs/REFERENCES.md).
Expand Down Expand Up @@ -36,7 +38,14 @@ For a fresh local demo, pass `--reset-demo` in the scheme's Run arguments or rei

## Revyl pull-request verification

The repository includes a published Revyl project configuration for automatic iOS preview builds and Proof of Changes. Its `pull-request` profile runs `scripts/build-ios.sh`; no GitHub Actions API key is required for Revyl-managed builds. See [Revyl setup and test-only sign-in](docs/REVYL.md) for session preparation, security boundaries, and verification commands. Configuration being enabled is not evidence that an individual proof passed; check that pull request's Revyl report.
The repository includes separate Revyl project configurations for Eats at `.revyl/config.yaml` and Rides at `apps/rides/.revyl/config.yaml`. Each has its own iOS app, session setup, path triggers, preview build, and Proof of Changes policy. No GitHub Actions API key is required for Revyl-managed builds.

```sh
revyl build --profile pull-request --platform ios
revyl -C apps/rides build --profile rides --platform ios
```

See [Revyl setup and test-only sign-in](docs/REVYL.md) for session preparation, security boundaries, and verification commands. Configuration being enabled is not evidence that an individual proof passed; check that pull request's Revyl report. Rides single-phone proof does not replace paired-phone verification against a reachable shared service.

## License and media

Expand Down
12 changes: 12 additions & 0 deletions apps/rides/.capyignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
build
DerivedData
*.xcresult
.generated
*.xcodeproj
.git
.build
.swiftpm
.revyl/before-session-values.json
.revyl/dev-sessions.json
.revyl/device-session.json
.revyl/device-history.json
5 changes: 5 additions & 0 deletions apps/rides/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.generated/
.revyl/before-session-values.json
.revyl/dev-sessions.json
.revyl/device-history.json
.revyl/device-session.json
5 changes: 5 additions & 0 deletions apps/rides/.revyl/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
*
!.gitignore
!config.yaml
!tests/
!tests/**
47 changes: 47 additions & 0 deletions apps/rides/.revyl/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
project:
id: b13e33ff-5c8d-490e-bb24-9ef1d4c06bba
session:
idle_timeout_seconds: 1800
before_script:
script_path: ./scripts/prepare-revyl-session.py
timeout_seconds: 30
auth_bypass:
launch_vars: [RIDES_AUTH_BYPASS_ENABLED, RIDES_AUTH_BYPASS_TOKEN]
deep_link: "ridesdemo://revyl-auth?token=${RIDES_AUTH_BYPASS_TOKEN}&role=rider&redirect=/home"
build:
framework: ios
profiles:
rides:
ios:
app_id: 1f375e45-bf9b-434a-b939-a8892a2297c3
build_commands:
- bash scripts/build-ios.sh
output_path: build/Build/Products/Debug-iphonesimulator/RidesDemo.app
timeout_seconds: 1800
pr_review:
enabled: true
review_triggers:
paths:
- apps/rides/**
- services/ride-demo/**
- docs/RIDES.md
- docs/RIDES_VERIFICATION.md
- .github/workflows/rides.yml
- .github/workflows/revyl.yml
- scripts/test_revyl_config.py
drafts: false
build:
kind: revyl
profile: rides
proof_of_changes:
enabled: true
harness:
kind: revyl
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.
strict_ci_check:
build: true
54 changes: 54 additions & 0 deletions apps/rides/App/RidesDemoApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import SwiftUI

@main
struct RidesDemoApp: App {
@StateObject private var store = RideStore()
var body: some Scene {
WindowGroup {
RideRootView()
.environmentObject(store)
.preferredColorScheme(.light)
.onOpenURL { url in Task { await store.handle(url) } }
}
}
}

struct RideRootView: View {
@EnvironmentObject private var store: RideStore
@Environment(\.scenePhase) private var scenePhase
var body: some View {
VStack(spacing: 0) {
if let status = store.revylStatus {
Text(status).font(.caption2).frame(maxWidth: .infinity).padding(5).background(Color.yellow.opacity(0.22))
}
if let error = store.errorMessage {
HStack(alignment: .top) {
Image(systemName: "exclamationmark.circle.fill")
Text(error).frame(maxWidth: .infinity, alignment: .leading)
if store.connected {
Button("Retry") { Task { await store.sync() } }.fontWeight(.semibold)
}
Button { store.errorMessage = nil } label: { Image(systemName: "xmark") }.accessibilityLabel("Dismiss error")
}.font(.caption).padding(12).background(Color.red.opacity(0.08)).foregroundStyle(.red)
}
TabView(selection: $store.tab) {
NavigationStack {
Group {
if store.role == .driver { DriverHomeView() }
else if let trip = store.activeTrip ?? store.focusedTrip { TripTrackingView(tripID: trip.id) }
else { RiderHomeView() }
}
.toolbar(.hidden, for: .navigationBar)
}.tabItem { Label("Home", systemImage: "house.fill") }.tag(0)
NavigationStack { RideServicesView() }.tabItem { Label(store.role == .driver ? "Earnings" : "Services", systemImage: store.role == .driver ? "chart.bar.fill" : "square.grid.3x3.fill") }.tag(1)
NavigationStack { RideActivityView() }.tabItem { Label("Activity", systemImage: "receipt.fill") }.tag(2)
NavigationStack { RideAccountView() }.tabItem { Label("Account", systemImage: "person.fill") }.tag(3)
}.tint(.black)
}
.sheet(isPresented: $store.showConnection) { DemoConnectionView().environmentObject(store) }
.fullScreenCover(isPresented: $store.showBooking) { BookingFlowView().environmentObject(store) }
.task(id: scenePhase) {
if scenePhase == .active { await store.runPolling() }
}
}
}
25 changes: 25 additions & 0 deletions apps/rides/Core/RevylRideSignIn.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

enum RevylRideSignIn {
enum Outcome: Equatable {
case ignored, rejected(String), accepted(DemoRole, Int)
}
static func launchValue(_ name: String, environment: [String: String] = ProcessInfo.processInfo.environment, arguments: [String] = ProcessInfo.processInfo.arguments) -> String? {
if let value = environment[name] { return value }
guard let index = arguments.firstIndex(of: "-" + name), arguments.indices.contains(index + 1) else { return nil }
return arguments[index + 1]
}
static func validate(_ url: URL, environment: [String: String], arguments: [String], isTestBuild: Bool) -> Outcome {
guard let parts = URLComponents(url: url, resolvingAgainstBaseURL: false), parts.scheme == "ridesdemo", parts.host == "revyl-auth" else { return .ignored }
guard isTestBuild else { return .rejected("Unavailable in production builds") }
guard launchValue("RIDES_AUTH_BYPASS_ENABLED", environment: environment, arguments: arguments) == "true" else { return .rejected("Session gate is disabled") }
guard let expected = launchValue("RIDES_AUTH_BYPASS_TOKEN", environment: environment, arguments: arguments), expected.count >= 32 else { return .rejected("Session token is missing or too short") }
guard parts.user == nil, parts.password == nil, parts.port == nil, parts.path.isEmpty, parts.fragment == nil else { return .rejected("Invalid sign-in link") }
let items = parts.queryItems ?? []
guard items.count == 3, Set(items.map(\.name)) == Set(["token", "role", "redirect"]) else { return .rejected("Invalid sign-in parameters") }
guard items.first(where: { $0.name == "token" })?.value == expected else { return .rejected("Invalid session token") }
guard let value = items.first(where: { $0.name == "role" })?.value, let role = DemoRole(rawValue: value) else { return .rejected("Role is not allowed") }
guard let redirect = items.first(where: { $0.name == "redirect" })?.value, let tab = ["/home": 0, "/services": 1, "/activity": 2, "/account": 3][redirect] else { return .rejected("Redirect is not allowed") }
return .accepted(role, tab)
}
}
74 changes: 74 additions & 0 deletions apps/rides/Core/RideAPI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

struct RideAPIError: LocalizedError, Equatable {
let status: Int
let message: String
var errorDescription: String? { message }
}

protocol RideAPI {
var baseURL: URL { get }
func create(name: String) async throws -> RideSession
func join(code: String, role: DemoRole, name: String) async throws -> RideSession
func state(token: String) async throws -> RideRoomState
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
}

struct RideAPIClient: RideAPI {
let baseURL: URL
var session = URLSession.shared
private struct Failure: Decodable { let error: String }

static func validatedURL(_ text: String) throws -> URL {
guard let parts = URLComponents(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
let url = parts.url, let host = parts.host, !host.isEmpty,
parts.user == nil, parts.password == nil, parts.query == nil, parts.fragment == nil,
parts.path.isEmpty || parts.path == "/",
parts.scheme == "https" || (parts.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(host)) else {
throw RideAPIError(status: 0, message: "Enter the HTTPS address of your demo server, without a path. Localhost HTTP is allowed for development.")
}
return url
}

func create(name: String) async throws -> RideSession {
struct Body: Encodable { let name: String }
return try await send("POST", "v1/rooms", body: JSONEncoder().encode(Body(name: name)))
}
func join(code: String, role: DemoRole, name: String) async throws -> RideSession {
struct Body: Encodable { let role: DemoRole; let name: String }
return try await send("POST", "v1/rooms/\(code)/join", body: JSONEncoder().encode(Body(role: role, name: name)))
}
func state(token: String) async throws -> RideRoomState { try await send("GET", "v1/state", token: token) }
func availability(token: String, online: Bool) async throws -> RideRoomState {
struct Body: Encodable { let online: Bool }
return try await send("POST", "v1/availability", token: token, body: JSONEncoder().encode(Body(online: online)))
}
func book(token: String, request: TripRequest) async throws -> RideRoomState {
try await send("POST", "v1/trips", token: token, body: JSONEncoder().encode(request))
}
func act(token: String, tripID: String, action: RideAction) async throws -> RideRoomState {
try await send("POST", "v1/trips/\(tripID)/actions", token: token, body: JSONEncoder().encode(action))
}
private func send<T: Decodable>(_ method: String, _ path: String, token: String? = nil, body: Data? = nil) async throws -> T {
var request = URLRequest(url: baseURL.appendingPathComponent(path))
request.httpMethod = method
request.httpBody = body
request.timeoutInterval = 12
request.cachePolicy = .reloadIgnoringLocalCacheData
request.setValue("application/json", forHTTPHeaderField: "Accept")
if body != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") }
if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
let (data, response) = try await session.data(for: request)
guard let response = response as? HTTPURLResponse else { throw RideAPIError(status: 0, message: "The server didn't return an HTTP response.") }
guard (200..<300).contains(response.statusCode) else {
let message = (try? JSONDecoder().decode(Failure.self, from: data).error) ?? "The demo server couldn't complete this request."
throw RideAPIError(status: response.statusCode, message: message)
}
return try JSONDecoder().decode(T.self, from: data)
}
}
Loading
Loading