diff --git a/.capyignore b/.capyignore index 792a7a3..52f05f5 100644 --- a/.capyignore +++ b/.capyignore @@ -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 diff --git a/.github/workflows/revyl.yml b/.github/workflows/revyl.yml new file mode 100644 index 0000000..7169d0b --- /dev/null +++ b/.github/workflows/revyl.yml @@ -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 diff --git a/.github/workflows/rides.yml b/.github/workflows/rides.yml new file mode 100644 index 0000000..e74eb35 --- /dev/null +++ b/.github/workflows/rides.yml @@ -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 diff --git a/.revyl/config.yaml b/.revyl/config.yaml index 459c1be..3f6930f 100644 --- a/.revyl/config.yaml +++ b/.revyl/config.yaml @@ -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 @@ -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. diff --git a/README.md b/README.md index ff7faec..4656e59 100644 --- a/README.md +++ b/README.md @@ -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). @@ -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 diff --git a/apps/rides/.capyignore b/apps/rides/.capyignore new file mode 100644 index 0000000..5d33a4c --- /dev/null +++ b/apps/rides/.capyignore @@ -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 diff --git a/apps/rides/.gitignore b/apps/rides/.gitignore new file mode 100644 index 0000000..b68749d --- /dev/null +++ b/apps/rides/.gitignore @@ -0,0 +1,5 @@ +.generated/ +.revyl/before-session-values.json +.revyl/dev-sessions.json +.revyl/device-history.json +.revyl/device-session.json diff --git a/apps/rides/.revyl/.gitignore b/apps/rides/.revyl/.gitignore new file mode 100644 index 0000000..fedc847 --- /dev/null +++ b/apps/rides/.revyl/.gitignore @@ -0,0 +1,5 @@ +* +!.gitignore +!config.yaml +!tests/ +!tests/** diff --git a/apps/rides/.revyl/config.yaml b/apps/rides/.revyl/config.yaml new file mode 100644 index 0000000..331f650 --- /dev/null +++ b/apps/rides/.revyl/config.yaml @@ -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 diff --git a/apps/rides/App/RidesDemoApp.swift b/apps/rides/App/RidesDemoApp.swift new file mode 100644 index 0000000..ed32b19 --- /dev/null +++ b/apps/rides/App/RidesDemoApp.swift @@ -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() } + } + } +} diff --git a/apps/rides/Core/RevylRideSignIn.swift b/apps/rides/Core/RevylRideSignIn.swift new file mode 100644 index 0000000..1432858 --- /dev/null +++ b/apps/rides/Core/RevylRideSignIn.swift @@ -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) + } +} diff --git a/apps/rides/Core/RideAPI.swift b/apps/rides/Core/RideAPI.swift new file mode 100644 index 0000000..806daf1 --- /dev/null +++ b/apps/rides/Core/RideAPI.swift @@ -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(_ 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) + } +} diff --git a/apps/rides/Core/RideModels.swift b/apps/rides/Core/RideModels.swift new file mode 100644 index 0000000..f5c292c --- /dev/null +++ b/apps/rides/Core/RideModels.swift @@ -0,0 +1,235 @@ +import Foundation + +enum DemoRole: String, Codable, CaseIterable, Identifiable { + case rider, driver + var id: String { rawValue } + var title: String { rawValue.capitalized } + var defaultName: String { self == .rider ? "Alex Morgan" : "Jordan Lee" } +} + +struct DemoPlace: Codable, Equatable, Identifiable, Hashable { + let id: String + var name: String + var address: String + let latitude: Double + let longitude: Double +} + +enum RideService: String, Codable, CaseIterable, Identifiable { + case uberX, comfort, uberXL, green, black, pet + var id: String { rawValue } + var title: String { + switch self { + case .uberX: return "UberX" + case .comfort: return "Comfort" + case .uberXL: return "UberXL" + case .green: return "Green" + case .black: return "Black" + case .pet: return "Uber Pet" + } + } + var detail: String { + switch self { + case .uberX: return "Affordable, everyday rides" + case .comfort: return "Newer cars with extra legroom" + case .uberXL: return "Affordable rides for up to 6" + case .green: return "A more sustainable ride" + case .black: return "Premium rides in luxury cars" + case .pet: return "A ride for you and your pet" + } + } + var seats: Int { self == .uberXL ? 6 : 4 } + var fareCents: Int { + switch self { + case .uberX: return 1840 + case .comfort: return 2490 + case .uberXL: return 2990 + case .green: return 1940 + case .black: return 3890 + case .pet: return 2140 + } + } + var icon: String { self == .uberXL ? "suv.side.fill" : "car.side.fill" } +} + +enum DemoPayment: String, Codable, CaseIterable, Identifiable { + case visa = "Demo Visa •••• 4242" + case cash = "Demo Cash" + case wallet = "Demo Wallet" + var id: String { rawValue } + var icon: String { self == .visa ? "creditcard.fill" : self == .cash ? "banknote.fill" : "wallet.pass.fill" } +} + +enum TripStatus: String, Codable { + case requested, accepted, arriving, inProgress = "in_progress", completed, cancelled + var isActive: Bool { self != .completed && self != .cancelled } + var canCancel: Bool { self == .requested || self == .accepted || self == .arriving } + var title: String { + switch self { + case .requested: return "Finding your ride" + case .accepted: return "Your driver is on the way" + case .arriving: return "Your driver has arrived" + case .inProgress: return "You're on your way" + case .completed: return "Thanks for riding, Alex" + case .cancelled: return "Your ride was cancelled" + } + } +} + +struct DemoDriver: Codable, Equatable, Identifiable { + let id: String + let name: String + let vehicle: String + let plate: String + let rating: Double +} + +struct RideMessage: Codable, Equatable, Identifiable { + let id: String + let senderId: String + let senderName: String + let text: String + let createdAt: String +} + +struct DemoTrip: Codable, Equatable, Identifiable { + let id: String + let riderId: String + let riderName: String + let pickup: DemoPlace + let destination: DemoPlace + let stops: [DemoPlace] + let service: String + let paymentMethod: String + let scheduledAt: String? + let note: String + let status: TripStatus + let driver: DemoDriver? + let fareCents: Int + let createdAt: String + let updatedAt: String + let cancellationReason: String? + let rating: Int? + let tipCents: Int + let messages: [RideMessage] + + var serviceTitle: String { RideService(rawValue: service)?.title ?? service } + var shortID: String { String(id.prefix(8)).uppercased() } + var totalCents: Int { fareCents + tipCents } + var canMessage: Bool { driver != nil && status.isActive } +} + +struct RideRoomState: Codable, Equatable { + let roomCode: String + let revision: Int + let driverOnline: Bool + let onlineDriverIds: [String] + let trips: [DemoTrip] + var activeTrip: DemoTrip? { trips.first { $0.status.isActive } } +} + +struct RideSession: Codable, Equatable { + let roomCode: String + let participantId: String + let token: String + let role: DemoRole + let name: String + let state: RideRoomState +} + +struct StoredRideSession: Codable { + let baseURL: URL + let roomCode: String + let participantId: String + let token: String + let role: DemoRole + let name: String + + init(_ session: RideSession, baseURL: URL) { + self.baseURL = baseURL + roomCode = session.roomCode + participantId = session.participantId + token = session.token + role = session.role + name = session.name + } +} + +struct BookingDraft: Equatable { + var requestId = UUID().uuidString + var pickup = RideCatalog.places[0] + var destination: DemoPlace? + var stops: [DemoPlace] = [] + var service: RideService = .uberX + var payment: DemoPayment = .visa + var scheduledAt: Date? + var note = "" + var fareCents: Int { service.fareCents + stops.count * 300 } + var routeIsValid: Bool { destination != nil && destination?.id != pickup.id && !stops.contains(pickup) && !stops.contains(where: { $0.id == destination?.id }) && Set(stops.map(\.id)).count == stops.count } + func validationError(now: Date = Date()) -> String? { + guard routeIsValid else { return "Choose different pickup, stop, and destination locations." } + guard stops.count <= 2 else { return "Add at most two stops." } + guard note.count <= 300 else { return "Keep your pickup note under 300 characters." } + if let scheduledAt, scheduledAt <= now || scheduledAt > now.addingTimeInterval(30 * 86400) { + return "Reserve between now and 30 days from now." + } + return nil + } +} + +struct TripRequest: Codable { + let requestId: String + let pickup: DemoPlace + let destination: DemoPlace + let stops: [DemoPlace] + let service: String + let paymentMethod: String + let scheduledAt: String? + let note: String + + init(draft: BookingDraft) throws { + if let error = draft.validationError() { throw RideAPIError(status: 0, message: error) } + guard let destination = draft.destination else { throw RideAPIError(status: 0, message: "Choose a destination.") } + requestId = draft.requestId + pickup = draft.pickup + self.destination = destination + stops = draft.stops + service = draft.service.rawValue + paymentMethod = draft.payment.rawValue + scheduledAt = draft.scheduledAt.map { ISO8601DateFormatter().string(from: $0) } + note = draft.note.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +struct RideAction: Codable { + var action: String + var reason: String? + var rating: Int? + var tipCents: Int? + var text: String? +} + +enum RideCatalog { + static let places: [DemoPlace] = [ + .init(id: "brazos", name: "701 Brazos Street", address: "Downtown Austin, TX", latitude: 30.2682, longitude: -97.7405), + .init(id: "airport", name: "Austin–Bergstrom International Airport", address: "3600 Presidential Blvd, Austin, TX", latitude: 30.1975, longitude: -97.6664), + .init(id: "home", name: "Home", address: "1313 East 6th Street, Austin, TX", latitude: 30.2635, longitude: -97.7290), + .init(id: "work", name: "Work", address: "600 Congress Avenue, Austin, TX", latitude: 30.2682, longitude: -97.7429), + .init(id: "coffee", name: "Jo's Coffee", address: "1300 South Congress Avenue, Austin, TX", latitude: 30.2503, longitude: -97.7493), + .init(id: "park", name: "Zilker Metropolitan Park", address: "2207 Lou Neff Road, Austin, TX", latitude: 30.2670, longitude: -97.7729), + .init(id: "hotel", name: "The LINE Austin", address: "111 East Cesar Chavez Street, Austin, TX", latitude: 30.2628, longitude: -97.7438), + .init(id: "museum", name: "Blanton Museum of Art", address: "200 East Martin Luther King Jr Blvd, Austin, TX", latitude: 30.2810, longitude: -97.7377) + ] + static func search(_ query: String, saved: [DemoPlace] = []) -> [DemoPlace] { + let places = saved + self.places.filter { place in !saved.contains { $0.id == place.id } } + let text = query.trimmingCharacters(in: .whitespacesAndNewlines) + return text.isEmpty ? places : places.filter { "\($0.name) \($0.address)".localizedCaseInsensitiveContains(text) } + } + static func money(_ cents: Int) -> String { (Double(cents) / 100).formatted(.currency(code: "USD")) } + static func date(_ value: String) -> String { + let parser = ISO8601DateFormatter() + parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let date = parser.date(from: value) ?? ISO8601DateFormatter().date(from: value) + return date?.formatted(date: .abbreviated, time: .shortened) ?? value + } +} diff --git a/apps/rides/Core/RideStore.swift b/apps/rides/Core/RideStore.swift new file mode 100644 index 0000000..0f63159 --- /dev/null +++ b/apps/rides/Core/RideStore.swift @@ -0,0 +1,242 @@ +import Combine +import Foundation +import Security + +protocol RideSessionVault { + func load() -> StoredRideSession? + func save(_ session: StoredRideSession) throws + func clear() +} + +struct KeychainRideSessionVault: RideSessionVault { + var account = "active-demo" + private var query: [String: Any] { + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "com.revyl.ridesdemo.session", kSecAttrAccount as String: account] + } + func load() -> StoredRideSession? { + var query = self.query + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var value: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &value) == errSecSuccess, let data = value as? Data else { return nil } + return try? JSONDecoder().decode(StoredRideSession.self, from: data) + } + func save(_ session: StoredRideSession) throws { + let data = try JSONEncoder().encode(session) + let attributes: [String: Any] = [kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecSuccess { return } + guard status == errSecItemNotFound else { throw RideAPIError(status: 0, message: "Couldn't securely save the demo session (Keychain \(status)).") } + var item = query + attributes.forEach { item[$0.key] = $0.value } + let added = SecItemAdd(item as CFDictionary, nil) + guard added == errSecSuccess else { throw RideAPIError(status: 0, message: "Couldn't securely save the demo session (Keychain \(added)).") } + } + func clear() { SecItemDelete(query as CFDictionary) } +} + +@MainActor +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 busy = false + @Published var errorMessage: String? + @Published var notice: String? + @Published var serverAddress: String + @Published var roomInput = "" + @Published var role: DemoRole + @Published var displayName: String + @Published var tab = 0 + @Published var draft = BookingDraft() + @Published var showBooking = false + @Published var showConnection = false + @Published var focusedTripID: String? + @Published var revylStatus: String? + @Published var savedPlaces: [DemoPlace] { + didSet { defaults.set(try? JSONEncoder().encode(savedPlaces), forKey: "savedPlaces") } + } + @Published var rideReminders: Bool { + didSet { defaults.set(rideReminders, forKey: "rideReminders") } + } + @Published var defaultPayment: DemoPayment { + didSet { defaults.set(defaultPayment.rawValue, forKey: "defaultPayment") } + } + private let defaults: UserDefaults + private let vault: any RideSessionVault + 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) }) { + 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") ?? "" + 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 + if let restored { api = apiFactory(restored.baseURL) } + } + + var connected: Bool { credentials != nil } + var isDriverOnline: Bool { state?.onlineDriverIds.contains(credentials?.participantId ?? "") == true } + var activeTrip: DemoTrip? { state?.activeTrip } + var focusedTrip: DemoTrip? { state?.trips.first { $0.id == focusedTripID } } + var myDriverTrip: DemoTrip? { activeTrip.flatMap { $0.driver?.id == credentials?.participantId ? $0 : nil } } + var pastTrips: [DemoTrip] { (state?.trips ?? []).filter { !$0.status.isActive } } + 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 } + + func chooseRole(_ value: DemoRole) { + guard !connected else { return } + role = value + displayName = value.defaultName + } + + func connect(createNew: Bool) async { + guard !busy, !connected else { return } + busy = true + defer { busy = false } + do { + let url = try RideAPIClient.validatedURL(serverAddress) + let name = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, name.count <= 60 else { throw RideAPIError(status: 0, message: "Enter a demo name of up to 60 characters.") } + guard !createNew || role == .rider else { throw RideAPIError(status: 0, message: "Create the room on the rider phone first.") } + let code = roomInput.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard createNew || (!code.isEmpty && code.count <= 32 && code.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber) })) else { throw RideAPIError(status: 0, message: "Enter the invitation code from the rider phone.") } + let client = apiFactory(url) + let session: RideSession + if createNew { session = try await client.create(name: name) } + else { session = try await client.join(code: code, role: role, name: name) } + let stored = StoredRideSession(session, baseURL: url) + try vault.save(stored) + api = client + credentials = stored + displayName = session.name + role = session.role + defaults.set(url.absoluteString, forKey: "serverAddress") + apply(session.state, for: stored) + errorMessage = nil + notice = "Connected as \(role.title.lowercased())." + } catch { errorMessage = error.localizedDescription } + } + + func disconnect() { + guard activeTrip == nil else { errorMessage = "Finish or cancel the active ride before leaving this demo."; return } + credentials = nil + api = nil + state = nil + lastSync = nil + focusedTripID = nil + roomInput = "" + vault.clear() + } + + func runPolling() async { + while !Task.isCancelled { + if connected { await sync() } + do { try await Task.sleep(for: .seconds(1)) } catch { return } + } + } + + func sync() async { + guard let credentials, let api else { return } + do { + let state = try await api.state(token: credentials.token) + 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." + } + } + } + + private func apply(_ incoming: RideRoomState, for credentials: StoredRideSession) { + guard self.credentials?.token == credentials.token, incoming.roomCode == credentials.roomCode else { return } + guard state == nil || incoming.revision >= state!.revision else { return } + state = incoming + lastSync = Date() + if errorMessage?.hasPrefix("Connection lost.") == true { errorMessage = nil } + } + + private func perform(_ action: (any RideAPI, StoredRideSession) async throws -> RideRoomState) async -> Bool { + guard !busy else { return false } + guard let api, let credentials else { showConnection = true; return false } + busy = true + defer { busy = false } + do { + let result = try await action(api, credentials) + apply(result, for: credentials) + errorMessage = nil + return true + } catch { + if (error as? RideAPIError)?.status == 409 { await sync() } + errorMessage = error.localizedDescription + return false + } + } + + func setOnline(_ online: Bool) async { + _ = await perform { api, session in try await api.availability(token: session.token, online: online) } + } + func book() async { + do { + let request = try TripRequest(draft: draft) + if await perform({ api, session in try await api.book(token: session.token, request: request) }) { + focusedTripID = activeTrip?.id + showBooking = false + tab = 0 + draft = BookingDraft() + } + } catch { errorMessage = error.localizedDescription } + } + @discardableResult + func act(_ trip: DemoTrip, _ action: RideAction) async -> Bool { + await perform { api, session in try await api.act(token: session.token, tripID: trip.id, action: action) } + } + func savePlace(_ place: DemoPlace) { + if !savedPlaces.contains(where: { $0.id == place.id }) { savedPlaces.append(place) } + } + func beginBooking(destination: DemoPlace? = nil, reserve: Bool = false) { + if activeTrip != nil { tab = 0; return } + draft = BookingDraft() + draft.payment = defaultPayment + draft.destination = destination + if reserve { draft.scheduledAt = Date().addingTimeInterval(3600) } + showBooking = true + } + func handle(_ url: URL, environment: [String: String] = ProcessInfo.processInfo.environment, arguments: [String] = ProcessInfo.processInfo.arguments) async { + #if DEBUG + switch RevylRideSignIn.validate(url, environment: environment, arguments: arguments, isTestBuild: true) { + case .ignored: return + case .rejected(let reason): revylStatus = "Revyl sign-in rejected: \(reason)" + case .accepted(let role, let tab): + guard credentials == nil || credentials?.role == role else { revylStatus = "Revyl sign-in rejected: Leave the current demo before switching roles"; return } + chooseRole(role) + self.tab = tab + revylStatus = "Revyl sign-in accepted · \(role.title)" + if let value = RevylRideSignIn.launchValue("RIDE_DEMO_BASE_URL", environment: environment, arguments: arguments) { serverAddress = value } + if let code = RevylRideSignIn.launchValue("RIDE_DEMO_ROOM_CODE", environment: environment, arguments: arguments), !code.isEmpty { + roomInput = code + await connect(createNew: false) + } + } + #endif + } +} diff --git a/apps/rides/Features/AccountActivityViews.swift b/apps/rides/Features/AccountActivityViews.swift new file mode 100644 index 0000000..31acf64 --- /dev/null +++ b/apps/rides/Features/AccountActivityViews.swift @@ -0,0 +1,174 @@ +import SwiftUI + +struct RideActivityView: View { + @EnvironmentObject private var store: RideStore + @State private var filter = "All" + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + Text("Activity").font(.system(size: 32, weight: .bold)).padding(.top, 12) + Text("Upcoming").font(.title3.bold()) + if let trip = store.activeTrip { + NavigationLink { TripTrackingView(tripID: trip.id) } label: { + VStack(alignment: .leading, spacing: 10) { + Text(trip.destination.name).font(.headline) + Text(trip.status.title).font(.subheadline).foregroundStyle(RideStyle.green) + if let date = trip.scheduledAt { Text(RideCatalog.date(date)).font(.caption) } + Label("View ride", systemImage: "arrow.right").font(.caption.bold()) + }.padding(18).frame(maxWidth: .infinity, alignment: .leading).overlay { RoundedRectangle(cornerRadius: 12).stroke(.gray.opacity(0.25)) } + }.foregroundStyle(.black) + } else { + HStack { + VStack(alignment: .leading, spacing: 8) { + Text("You have no upcoming trips").font(.headline) + if store.role == .rider { Button("Reserve your ride →") { store.beginBooking(reserve: true) }.font(.subheadline) } + else { Text("Go online to receive new requests.").font(.subheadline).foregroundStyle(.secondary) } + } + Spacer() + Image(systemName: "calendar.badge.clock").font(.system(size: 32)).foregroundStyle(.gray) + }.padding(18).overlay { RoundedRectangle(cornerRadius: 12).stroke(.gray.opacity(0.25)) } + } + HStack { + Text("Past").font(.title3.bold()) + Spacer() + Menu { Picker("Show trips", selection: $filter) { ForEach(["All", "Completed", "Cancelled"], id: \.self) { Text($0).tag($0) } } } label: { Image(systemName: "line.3.horizontal.decrease").padding(9).background(RideStyle.soft).clipShape(Circle()) }.accessibilityLabel("Filter trips") + } + let trips = store.pastTrips.filter { filter == "All" || (filter == "Completed" ? $0.status == .completed : $0.status == .cancelled) } + if trips.isEmpty { Text(store.connected ? "Your completed and cancelled demo rides will appear here." : "Connect a demo room to see shared trip history.").font(.subheadline).foregroundStyle(.secondary).padding(.vertical, 24) } + ForEach(trips) { trip in + VStack(alignment: .leading, spacing: 12) { + NavigationLink { TripTrackingView(tripID: trip.id) } label: { + VStack(alignment: .leading, spacing: 10) { + DemoMapView().frame(height: 115).clipShape(RoundedRectangle(cornerRadius: 9)) + Text(trip.destination.name).font(.headline).foregroundStyle(.black) + Text(RideCatalog.date(trip.createdAt)).font(.caption).foregroundStyle(.secondary) + Text("\(trip.status == .cancelled ? "$0.00" : RideCatalog.money(trip.totalCents)) · \(trip.status == .cancelled ? "Cancelled" : trip.serviceTitle)").font(.caption).foregroundStyle(.secondary) + } + } + if store.role == .rider { + Button { store.beginBooking(destination: trip.destination); store.draft.pickup = trip.pickup; store.draft.service = RideService(rawValue: trip.service) ?? .uberX; store.draft.stops = trip.stops } label: { Label("Rebook", systemImage: "arrow.clockwise").font(.caption.bold()).padding(9).background(RideStyle.soft).clipShape(Capsule()) }.foregroundStyle(.black) + } + }.padding(14).overlay { RoundedRectangle(cornerRadius: 12).stroke(.gray.opacity(0.18)) } + } + DemoConnectionBadge().padding(.bottom, 15) + }.padding(20) + }.navigationBarTitleDisplayMode(.inline).tint(.black) + } +} + +struct RideAccountView: View { + @EnvironmentObject private var store: RideStore + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + HStack { + VStack(alignment: .leading, spacing: 10) { + Text(store.displayName).font(.system(size: 30, weight: .bold)) + Label("5.00 · Demo \(store.role.title.lowercased())", systemImage: "star.fill").font(.caption.bold()).padding(7).background(RideStyle.soft).clipShape(Capsule()) + } + Spacer() + Image(systemName: "person.crop.circle.fill").font(.system(size: 62)).foregroundStyle(.gray.opacity(0.5)) + }.padding(.top, 15) + HStack(spacing: 12) { + NavigationLink { RideHelpView() } label: { accountTile("Help", icon: "questionmark.circle.fill") } + NavigationLink { RideWalletView(payment: $store.defaultPayment) } label: { accountTile("Wallet", icon: "wallet.pass.fill") } + Button { store.tab = 2 } label: { accountTile("Activity", icon: "receipt.fill") } + }.foregroundStyle(.black) + Button { store.showConnection = true } label: { + HStack(spacing: 15) { + Image(systemName: "iphone.gen3.radiowaves.left.and.right").font(.system(size: 26)) + VStack(alignment: .leading, spacing: 6) { + Text("Rider & driver demo").font(.headline) + Text(store.connected ? "Share your room or view connection details" : "Connect two phones to the same room").font(.caption).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right").font(.caption) + }.padding(18).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 12)) + }.foregroundStyle(.black) + VStack(spacing: 0) { + NavigationLink { SavedRidePlacesView() } label: { accountRow("Saved places", icon: "star.fill") } + NavigationLink { RideSettingsView() } label: { accountRow("Settings", icon: "gearshape.fill") } + NavigationLink { RideInfoView(title: "Privacy", icon: "hand.raised.fill", text: "Demo names, trip details, and messages are stored within the private invitation room on your configured server. Session credentials stay in the iOS Keychain. Rooms expire after 24 hours; the database stays until its operator deletes it. No device location, contacts, payment details, or audio are collected.") } label: { accountRow("Privacy", icon: "lock.fill") } + NavigationLink { RideInfoView(title: "About this demo", icon: "info.circle.fill", text: "An independent SwiftUI ride demo inspired by Uber's rider workflows; not affiliated with Uber. Rider and driver share booking, acceptance, progress, and messages through a demo server. Maps, vehicles, prices, payment methods, and ratings are sample data. Eats remains a separate native app in this repository.") } label: { accountRow("About", icon: "info.circle.fill") } + }.foregroundStyle(.black) + DemoConnectionBadge() + Text("Rides Demo 0.1 · No real rides or charges").font(.caption2).foregroundStyle(.secondary) + }.padding(20) + }.navigationBarTitleDisplayMode(.inline).tint(.black) + } + private func accountTile(_ title: String, icon: String) -> some View { + VStack(spacing: 12) { Image(systemName: icon).font(.system(size: 26)); Text(title).font(.subheadline.bold()) }.frame(maxWidth: .infinity).padding(.vertical, 22).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 12)) + } + private func accountRow(_ title: String, icon: String) -> some View { + HStack(spacing: 18) { Image(systemName: icon).frame(width: 24); Text(title).font(.system(size: 16, weight: .medium)); Spacer(); Image(systemName: "chevron.right").font(.caption).foregroundStyle(.secondary) }.padding(.vertical, 20) + } +} + +struct RideWalletView: View { + @Binding var payment: DemoPayment + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 12) { + Text("Demo Wallet").font(.headline) + Text("$50.00").font(.system(size: 40, weight: .bold)) + Text("Example balance only. No funds are stored, added, or deducted.").font(.caption) + }.padding(24).frame(maxWidth: .infinity, alignment: .leading).background(Color(red: 0.86, green: 0.94, blue: 0.89)).clipShape(RoundedRectangle(cornerRadius: 14)) + Text("Payment methods").font(.title2.bold()) + ForEach(DemoPayment.allCases) { method in + Button { payment = method } label: { + HStack(spacing: 15) { Image(systemName: method.icon).frame(width: 25); Text(method.rawValue).font(.subheadline.bold()); Spacer(); Image(systemName: payment == method ? "checkmark.circle.fill" : "circle").font(.system(size: 23)) }.padding(.vertical, 16).foregroundStyle(.black) + } + Divider() + } + Label("Test methods only", systemImage: "lock.shield.fill").font(.headline) + Text("No card entry or real payment processing is enabled. The selected method appears on the demo booking and receipt.").foregroundStyle(.secondary) + }.padding(20) + }.navigationTitle("Wallet").navigationBarTitleDisplayMode(.inline).toolbar(.visible, for: .navigationBar).tint(.black) + } +} + +struct SavedRidePlacesView: View { + @EnvironmentObject private var store: RideStore + @State private var add = false + var body: some View { + List { + Section("Your saved demo locations") { + ForEach(store.savedPlaces) { place in RideLocationRow(place: place, icon: "star.fill") } + .onDelete { store.savedPlaces.remove(atOffsets: $0) } + } + Button { add = true } label: { Label("Add a saved place", systemImage: "plus") } + Section { Text("Choose from the offline demo catalog. Swipe to remove a place. Saved locations stay on this phone.").font(.caption).foregroundStyle(.secondary) } + }.navigationTitle("Saved places").navigationBarTitleDisplayMode(.inline).sheet(isPresented: $add) { PlaceSearchView(title: "Save a place") { store.savePlace($0) }.environmentObject(store) }.tint(.black) + } +} + +struct RideSettingsView: View { + @EnvironmentObject private var store: RideStore + var body: some View { + Form { + Section("Profile") { LabeledContent("Name", value: store.displayName); LabeledContent("Mode", value: store.role.title); Text("Choose your name and mode before joining a room in Demo connection.").font(.caption).foregroundStyle(.secondary) } + Section("Preferences") { Toggle("Show reservation reminder in app", isOn: $store.rideReminders).tint(.black); Text("In-app reminder only. No push notifications are sent.").font(.caption).foregroundStyle(.secondary) } + Section("Demo") { Button("Demo connection & invitation") { store.showConnection = true }; NavigationLink("About the demo") { RideInfoView(title: "Demo limitations", icon: "car.side.fill", text: "Driver movement and travel estimates are simulated. The driver manually marks arrival, start, and completion. Reservations appear immediately, and demo payments never move money. No third-party login, SMS, geocoding, real dispatch, or emergency service is used.") } } + }.navigationTitle("Settings").navigationBarTitleDisplayMode(.inline).tint(.black) + } +} + +struct RideHelpView: View { + @EnvironmentObject private var store: RideStore + var body: some View { + List { + Section("Your last trip") { + if let trip = store.state?.trips.first { NavigationLink { TripTrackingView(tripID: trip.id) } label: { VStack(alignment: .leading, spacing: 7) { Text(trip.destination.name).font(.headline); Text(RideCatalog.date(trip.createdAt)).font(.caption).foregroundStyle(.secondary) } } } + else { Text("No trips in this demo room yet.").foregroundStyle(.secondary) } + } + Section("Help topics") { + NavigationLink("Connecting the two phones") { RideInfoView(title: "Connect your demo", icon: "iphone.gen3.radiowaves.left.and.right", text: "Start the shared server at an HTTPS address reachable from both phones. On the rider phone, open Account → Rider & driver demo, enter the server, and create a room. Share that private invitation with the other phone, choose Driver, join the room, and tap GO. A rider booking then appears on the driver phone.") } + NavigationLink("Trip progress and reservations") { RideInfoView(title: "Demo ride progress", icon: "car.side.fill", text: "The driver accepts, marks arrival, starts, and completes the ride. Each committed state appears on the rider phone after its next sync, normally within a second. Nothing auto-accepts or advances a trip. Reserved rides carry their pickup time but can be manually advanced immediately for demos.") } + NavigationLink("Payments and receipts") { RideInfoView(title: "Demo payments", icon: "creditcard.fill", text: "All fares, tips, and payment methods are simulated. A receipt records the chosen method and amount, but no bank or payment provider is charged. Do not enter real card information.") } + NavigationLink("Connection problems") { RideInfoView(title: "Connection problems", icon: "wifi.exclamationmark", text: "Both phones need access to the same running server. If it stops or the preview workspace sleeps, the app shows a connection error and keeps its last synced state. Restart the server and tap Retry. Expired rooms need a new invitation.") } + } + Section { Text("No real support requests, lost-item claims, or emergency calls are submitted from this app.").font(.caption).foregroundStyle(.secondary) } + }.navigationTitle("Help").navigationBarTitleDisplayMode(.inline).tint(.black) + } +} diff --git a/apps/rides/Features/BookingFlowView.swift b/apps/rides/Features/BookingFlowView.swift new file mode 100644 index 0000000..436155b --- /dev/null +++ b/apps/rides/Features/BookingFlowView.swift @@ -0,0 +1,161 @@ +import SwiftUI + +struct BookingFlowView: View { + @EnvironmentObject private var store: RideStore + @Environment(\.dismiss) private var dismiss + @State private var step = 0 + @State private var query = "" + @State private var placePicker: String? + @State private var showSchedule = false + @State private var showPayment = false + @State private var showConnect = false + @State private var schedule = Date().addingTimeInterval(3600) + var body: some View { + NavigationStack { + VStack(spacing: 0) { + if step == 0 { planView } + else { + ZStack(alignment: .topLeading) { + DemoMapView().frame(height: step == 1 ? 210 : 280) + Button { step -= 1 } label: { Image(systemName: "arrow.left").font(.system(size: 20, weight: .medium)).frame(width: 43, height: 43).background(.white).clipShape(Circle()) }.padding(16) + } + if step == 1 { chooseRideView } + else { confirmPickupView } + } + if step > 0 { footer } + } + .background(.white) + .navigationTitle(step == 0 ? "Plan your ride" : "") + .navigationBarTitleDisplayMode(.inline) + .toolbar(step == 0 ? .visible : .hidden, for: .navigationBar) + .toolbar { ToolbarItem(placement: .topBarLeading) { Button { dismiss() } label: { Image(systemName: "arrow.left") }.accessibilityLabel("Close booking") } } + .sheet(isPresented: $showSchedule) { + NavigationStack { + VStack(alignment: .leading, spacing: 24) { + Text("When do you want to be picked up?").font(.system(size: 28, weight: .bold)) + DatePicker("Pickup time", selection: $schedule, in: Date().addingTimeInterval(60)...Date().addingTimeInterval(30 * 86400), displayedComponents: [.date, .hourAndMinute]).datePickerStyle(.graphical) + Text("Reserve up to 30 days ahead. In this demo, the driver manually advances reservations immediately; there is no timed dispatch.").font(.footnote).foregroundStyle(.secondary) + RidePrimaryButton(title: "Confirm pickup time") { store.draft.scheduledAt = schedule; showSchedule = false } + Button("Ride now instead") { store.draft.scheduledAt = nil; showSchedule = false }.frame(maxWidth: .infinity) + Spacer() + }.padding(20).navigationTitle("Reserve a ride").navigationBarTitleDisplayMode(.inline).toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { showSchedule = false } } } + }.tint(.black) + } + .sheet(isPresented: $showPayment) { NavigationStack { RideWalletView(payment: $store.draft.payment).toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { showPayment = false } } } }.tint(.black) } + .sheet(isPresented: $showConnect) { DemoConnectionView().environmentObject(store) } + .sheet(isPresented: Binding(get: { placePicker != nil }, set: { if !$0 { placePicker = nil } })) { + PlaceSearchView(title: placePicker == "pickup" ? "Pickup location" : "Add a stop") { place in + if placePicker == "pickup" { store.draft.pickup = place } + else if store.draft.stops.count < 2 { store.draft.stops.append(place) } + placePicker = nil + } + } + .onAppear { + if store.draft.destination != nil { step = 1 } + if let date = store.draft.scheduledAt { schedule = date } + } + }.tint(.black).interactiveDismissDisabled(store.busy) + } + + private var planView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + HStack { + Label("For me", systemImage: "person.fill").font(.system(size: 13, weight: .medium)) + Spacer() + Button { showSchedule = true } label: { Label(store.draft.scheduledAt.map { $0.formatted(date: .abbreviated, time: .shortened) } ?? "Pickup now", systemImage: "clock.fill").font(.system(size: 13, weight: .medium)).padding(10).background(RideStyle.soft).clipShape(Capsule()) } + } + VStack(spacing: 0) { + Button { placePicker = "pickup" } label: { RideLocationRow(place: store.draft.pickup, icon: "circle.inset.filled") } + Divider() + HStack(spacing: 14) { + Image(systemName: "square.fill").frame(width: 28) + TextField("Where to?", text: $query).font(.system(size: 17)).submitLabel(.search).accessibilityIdentifier("booking.destination") + if store.draft.stops.count < 2 { Button { placePicker = "stop" } label: { Image(systemName: "plus").font(.system(size: 20)) }.accessibilityLabel("Add a stop") } + }.padding(.vertical, 18) + }.padding(.horizontal, 14).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 10)) + ForEach(store.draft.stops) { stop in + HStack { RideLocationRow(place: stop, icon: "circle"); Button { store.draft.stops.removeAll { $0.id == stop.id } } label: { Image(systemName: "minus.circle.fill") }.accessibilityLabel("Remove \(stop.name)") } + } + if !store.draft.stops.isEmpty { Text("Each demo stop adds $3.00 to the fare.").font(.caption).foregroundStyle(.secondary) } + Text("Suggested destinations").font(.subheadline.bold()) + ForEach(RideCatalog.search(query, saved: store.savedPlaces).filter { $0.id != store.draft.pickup.id && !store.draft.stops.contains($0) }) { place in + Button { store.draft.destination = place; step = 1 } label: { RideLocationRow(place: place, icon: store.savedPlaces.contains(place) ? "star.fill" : "mappin.circle.fill") } + Divider().padding(.leading, 42) + } + if RideCatalog.search(query, saved: store.savedPlaces).isEmpty { Text("No demo locations found. Try Airport, Home, Work, or Coffee.").font(.subheadline).foregroundStyle(.secondary).padding(.vertical, 20) } + Text("Locations and routes use an offline Austin demo catalog. No live geocoding.").font(.caption).foregroundStyle(.secondary) + }.padding(20) + }.scrollDismissesKeyboard(.interactively) + } + + private var chooseRideView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text("Choose a ride").font(.system(size: 22, weight: .bold)).frame(maxWidth: .infinity).padding(.bottom, 6) + ForEach(RideService.allCases) { service in + Button { store.draft.service = service } label: { + HStack(spacing: 9) { + RideCarArt(service: service) + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { Text(service.title).font(.system(size: 17, weight: .semibold)); Label("\(service.seats)", systemImage: "person.fill").font(.system(size: 10)) } + Text(store.draft.scheduledAt == nil ? "4 min away · Demo estimate" : "Scheduled demo pickup").font(.system(size: 11)).foregroundStyle(.secondary) + Text(service.detail).font(.system(size: 11)).foregroundStyle(.secondary) + }.frame(maxWidth: .infinity, alignment: .leading) + Text(RideCatalog.money(service.fareCents + store.draft.stops.count * 300)).font(.system(size: 15, weight: .semibold)) + }.padding(.vertical, 12).padding(.horizontal, 8).background(.white).clipShape(RoundedRectangle(cornerRadius: 12)).overlay { RoundedRectangle(cornerRadius: 12).stroke(store.draft.service == service ? Color.black : Color.clear, lineWidth: 2) } + }.foregroundStyle(.black).accessibilityIdentifier("booking.service.\(service.rawValue)") + } + }.padding(18) + } + } + + private var confirmPickupView: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Text("Confirm your pickup").font(.system(size: 25, weight: .bold)) + Text("Meet your driver at the pickup spot. This is a simulated trip, not a real booking.").font(.subheadline).foregroundStyle(.secondary) + if let destination = store.draft.destination { + RideRouteSummary(pickup: store.draft.pickup, destination: destination, stops: store.draft.stops) + } + if let date = store.draft.scheduledAt { + Label(date.formatted(date: .complete, time: .shortened), systemImage: "calendar").font(.subheadline.bold()) + Text("Reservations appear on the driver phone immediately and are manually advanced in this demo.").font(.caption).foregroundStyle(.secondary) + } + TextField("Add a pickup note (optional)", text: $store.draft.note, axis: .vertical).lineLimit(2...4).padding(14).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 10)) + Text("You can cancel before the ride starts. No cancellation fees or real charges apply.").font(.caption).foregroundStyle(.secondary) + }.padding(20) + }.scrollDismissesKeyboard(.interactively) + } + + private var footer: some View { + VStack(spacing: 12) { + Divider() + Button { showPayment = true } label: { + HStack { Image(systemName: store.draft.payment.icon); Text(store.draft.payment.rawValue).font(.system(size: 14, weight: .medium)); Spacer(); Image(systemName: "chevron.right").font(.caption) }.foregroundStyle(.black) + } + if let error = store.errorMessage { Text(error).font(.caption).foregroundStyle(.red).frame(maxWidth: .infinity, alignment: .leading) } + RidePrimaryButton(title: step == 1 ? "Choose \(store.draft.service.title)" : store.connected ? "Confirm pickup · \(RideCatalog.money(store.draft.fareCents))" : "Connect to request a ride", busy: store.busy, disabled: store.draft.validationError() != nil) { + if step == 1 { step = 2 } + else if !store.connected { showConnect = true } + else { Task { await store.book() } } + } + Text("Demo fare · No payment will be collected").font(.system(size: 10)).foregroundStyle(.secondary) + }.padding(.horizontal, 20).padding(.bottom, 12).background(.white) + } +} + +struct PlaceSearchView: View { + @EnvironmentObject private var store: RideStore + @Environment(\.dismiss) private var dismiss + var title: String + var onSelect: (DemoPlace) -> Void + @State private var query = "" + var body: some View { + NavigationStack { + List(RideCatalog.search(query, saved: store.savedPlaces)) { place in + Button { onSelect(place); dismiss() } label: { RideLocationRow(place: place) } + }.listStyle(.plain).searchable(text: $query, prompt: "Search demo locations").navigationTitle(title).navigationBarTitleDisplayMode(.inline).toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } } } + }.tint(.black) + } +} diff --git a/apps/rides/Features/ConnectionView.swift b/apps/rides/Features/ConnectionView.swift new file mode 100644 index 0000000..19f2e5c --- /dev/null +++ b/apps/rides/Features/ConnectionView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct DemoConnectionView: View { + @EnvironmentObject private var store: RideStore + @Environment(\.dismiss) private var dismiss + @State private var joinExisting = false + @State private var copied = false + @State private var confirmLeave = false + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + HStack { + Image(systemName: "iphone").font(.system(size: 36)) + Image(systemName: "arrow.left.arrow.right").font(.system(size: 20)) + Image(systemName: "iphone").font(.system(size: 36)) + }.padding(.top, 10) + Text(store.connected ? "Your shared demo" : "One ride. Two phones.").font(.system(size: 31, weight: .bold)) + Text("Create a room on the rider phone. On the driver phone, choose Driver and join with the same server address and invitation code.").font(.subheadline).foregroundStyle(.secondary) + if let session = store.credentials { + VStack(alignment: .leading, spacing: 12) { + Label("\(session.name) · \(session.role.title)", systemImage: session.role == .rider ? "person.fill" : "car.fill").font(.headline) + Text("Invitation code").font(.caption).foregroundStyle(.secondary) + Text(session.roomCode).font(.system(size: 30, weight: .bold, design: .monospaced)).tracking(3).textSelection(.enabled) + Text(session.baseURL.absoluteString).font(.caption).textSelection(.enabled) + Button(copied ? "Copied invitation" : "Copy invitation") { + UIPasteboard.general.string = "Rides demo\nServer: \(session.baseURL.absoluteString)\nRoom: \(session.roomCode)\nChoose Driver to join. Keep this invitation private." + copied = true + }.font(.subheadline.bold()) + }.padding(18).frame(maxWidth: .infinity, alignment: .leading).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 14)) + RidePrimaryButton(title: "Continue as \(session.role.title)") { dismiss() } + Button("Leave this demo", role: .destructive) { confirmLeave = true }.disabled(store.activeTrip != nil) + if store.activeTrip != nil { Text("Finish or cancel the active ride before leaving.").font(.caption).foregroundStyle(.secondary) } + } else { + HStack(spacing: 12) { + ForEach(DemoRole.allCases) { role in + Button { store.chooseRole(role) } label: { + VStack(spacing: 9) { + Image(systemName: role == .rider ? "person.crop.circle.fill" : "car.side.fill").font(.system(size: 30)) + Text(role.title).font(.headline) + }.frame(maxWidth: .infinity).padding(18).background(store.role == role ? Color.black : RideStyle.soft).foregroundStyle(store.role == role ? Color.white : Color.black).clipShape(RoundedRectangle(cornerRadius: 12)) + }.disabled(store.busy) + } + } + VStack(alignment: .leading, spacing: 7) { + Text("Demo server").font(.subheadline.bold()) + TextField("https://your-demo-server", text: $store.serverAddress).keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled().padding(14).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 9)).accessibilityIdentifier("demo.server") + Text("The server must be reachable by both phones. No public server is bundled.").font(.caption).foregroundStyle(.secondary) + } + VStack(alignment: .leading, spacing: 7) { + Text("Your demo name").font(.subheadline.bold()) + TextField("Name", text: $store.displayName).textContentType(.name).padding(14).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 9)) + } + if store.role == .rider { Toggle("Join an existing room", isOn: $joinExisting).tint(.black) } + if store.role == .driver || joinExisting { + TextField("Invitation code", text: $store.roomInput).textInputAutocapitalization(.characters).autocorrectionDisabled().padding(14).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 9)).accessibilityIdentifier("demo.room") + } + if let error = store.errorMessage { Text(error).font(.caption).foregroundStyle(.red) } + RidePrimaryButton(title: store.role == .rider && !joinExisting ? "Create rider room" : "Join as \(store.role.title)", busy: store.busy, disabled: store.serverAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) { + Task { + await store.connect(createNew: store.role == .rider && !joinExisting) + if store.connected && (store.role == .driver || joinExisting) { dismiss() } + } + } + } + VStack(alignment: .leading, spacing: 8) { + Label("A shared demo, not a real ride", systemImage: "info.circle").font(.subheadline.bold()) + Text("Trip requests, acceptance, messages, and ride progress sync through your server. Maps, fares, profiles, and payment methods are sample data. Anyone with the private invitation can join this room.").font(.caption).foregroundStyle(.secondary) + }.padding(.bottom, 20) + }.padding(20) + }.scrollDismissesKeyboard(.interactively) + .navigationTitle("Demo connection").navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() }.disabled(store.busy) } } + .confirmationDialog("Leave this demo?", isPresented: $confirmLeave, titleVisibility: .visible) { + Button("Leave demo", role: .destructive) { store.disconnect() } + } message: { Text("The room and its trip history stay on the server until the room expires. Keep the invitation to rejoin as the rider.") } + }.tint(.black) + } +} diff --git a/apps/rides/Features/RideDesign.swift b/apps/rides/Features/RideDesign.swift new file mode 100644 index 0000000..eee631a --- /dev/null +++ b/apps/rides/Features/RideDesign.swift @@ -0,0 +1,156 @@ +import SwiftUI + +enum RideStyle { + static let soft = Color(red: 0.95, green: 0.95, blue: 0.95) + static let green = Color(red: 0.02, green: 0.44, blue: 0.27) +} + +struct RidePrimaryButton: View { + var title: String + var busy = false + var disabled = false + var action: () -> Void + var body: some View { + Button(action: action) { + HStack { + Spacer() + if busy { ProgressView().tint(.white) } + Text(title).font(.system(size: 17, weight: .semibold)) + Spacer() + }.padding(.vertical, 17).background(disabled ? Color.gray : Color.black).foregroundStyle(.white).clipShape(RoundedRectangle(cornerRadius: 10)) + }.disabled(disabled || busy) + } +} + +struct RideCarArt: View { + var service: RideService = .uberX + var body: some View { + ZStack(alignment: .bottom) { + Ellipse().fill(.black.opacity(0.10)).frame(width: 74, height: 10).blur(radius: 3).offset(y: 3) + Image(systemName: service.icon) + .font(.system(size: 48, weight: .regular)) + .foregroundStyle(service == .black ? Color.black.gradient : Color(white: 0.56).gradient) + .shadow(color: .black.opacity(0.14), radius: 2, x: 1, y: 3) + if service == .green { Image(systemName: "leaf.fill").font(.system(size: 13)).foregroundStyle(RideStyle.green).offset(x: 27, y: -29) } + if service == .pet { Image(systemName: "pawprint.fill").font(.system(size: 13)).offset(x: 28, y: -29) } + }.frame(width: 84, height: 54).accessibilityHidden(true) + } +} + +struct DemoMapView: View { + var route = true + var status: TripStatus = .requested + var body: some View { + GeometryReader { geometry in + ZStack { + Canvas { context, size in + context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(Color(red: 0.91, green: 0.93, blue: 0.95))) + var park = Path() + park.move(to: CGPoint(x: size.width * 0.79, y: 0)) + park.addCurve(to: CGPoint(x: size.width * 0.18, y: size.height), control1: CGPoint(x: size.width * 0.96, y: size.height * 0.45), control2: CGPoint(x: size.width * 0.14, y: size.height * 0.56)) + park.addLine(to: CGPoint(x: size.width * 0.43, y: size.height)) + park.addCurve(to: CGPoint(x: size.width * 0.94, y: 0), control1: CGPoint(x: size.width * 0.51, y: size.height * 0.64), control2: CGPoint(x: size.width * 1.08, y: size.height * 0.34)) + park.closeSubpath() + context.fill(park, with: .color(Color(red: 0.70, green: 0.83, blue: 0.72))) + for index in 0..<11 { + let x = CGFloat(index) * size.width / 8 - size.width * 0.1 + var street = Path() + street.move(to: CGPoint(x: x, y: -20)) + street.addLine(to: CGPoint(x: x - size.width * 0.24, y: size.height + 20)) + context.stroke(street, with: .color(.white), lineWidth: index % 3 == 0 ? 8 : 4) + } + for index in 0..<10 { + let y = CGFloat(index) * size.height / 7 + var street = Path() + street.move(to: CGPoint(x: -10, y: y)) + street.addLine(to: CGPoint(x: size.width + 10, y: y + size.height * 0.12)) + context.stroke(street, with: .color(.white), lineWidth: index % 3 == 0 ? 8 : 4) + } + var river = Path() + river.move(to: CGPoint(x: -10, y: size.height * 0.71)) + river.addCurve(to: CGPoint(x: size.width + 10, y: size.height * 0.95), control1: CGPoint(x: size.width * 0.48, y: size.height * 0.48), control2: CGPoint(x: size.width * 0.57, y: size.height * 1.08)) + context.stroke(river, with: .color(Color(red: 0.63, green: 0.80, blue: 0.89)), style: StrokeStyle(lineWidth: 20, lineCap: .round)) + if route { + var line = Path() + line.move(to: CGPoint(x: size.width * 0.25, y: size.height * 0.27)) + line.addLine(to: CGPoint(x: size.width * 0.57, y: size.height * 0.31)) + line.addLine(to: CGPoint(x: size.width * 0.49, y: size.height * 0.60)) + line.addLine(to: CGPoint(x: size.width * 0.77, y: size.height * 0.64)) + context.stroke(line, with: .color(.white), style: StrokeStyle(lineWidth: 7, lineCap: .round, lineJoin: .round)) + context.stroke(line, with: .color(.black), style: StrokeStyle(lineWidth: 3.5, lineCap: .round, lineJoin: .round)) + } + } + Text("DOWNTOWN").font(.system(size: 10, weight: .medium)).tracking(2).foregroundStyle(.gray).position(x: geometry.size.width * 0.40, y: geometry.size.height * 0.16) + Text("Congress Ave").font(.system(size: 9)).foregroundStyle(.gray).rotationEffect(.degrees(-73)).position(x: geometry.size.width * 0.32, y: geometry.size.height * 0.49) + Text("Lady Bird Lake").font(.system(size: 10)).foregroundStyle(Color(red: 0.31, green: 0.49, blue: 0.63)).rotationEffect(.degrees(12)).position(x: geometry.size.width * 0.60, y: geometry.size.height * 0.87) + if route { + Image(systemName: "circle.inset.filled").font(.system(size: 17)).position(x: geometry.size.width * 0.25, y: geometry.size.height * 0.27) + Image(systemName: "square.fill").font(.system(size: 14)).position(x: geometry.size.width * 0.77, y: geometry.size.height * 0.64) + Text("Pickup spot").font(.system(size: 12, weight: .medium)).padding(7).background(.white).shadow(color: .black.opacity(0.1), radius: 4).position(x: geometry.size.width * 0.27, y: geometry.size.height * 0.37) + } + Image(systemName: "car.top.door.front.left.and.front.right.closed.fill") + .font(.system(size: 28)).foregroundStyle(.black) + .rotationEffect(.degrees(status == .inProgress ? 100 : 20)) + .position(x: geometry.size.width * (status == .arriving ? 0.30 : status == .inProgress ? 0.64 : 0.52), y: geometry.size.height * (status == .arriving ? 0.30 : 0.60)) + VStack { Spacer(); HStack { Spacer(); Text("Simulated map · Austin").font(.system(size: 8)).foregroundStyle(.secondary).padding(6).background(.white.opacity(0.8)) } } + } + }.clipped().accessibilityLabel("Simulated Austin map\(route ? " with pickup and destination route" : "")") + } +} + +struct DemoConnectionBadge: View { + @EnvironmentObject private var store: RideStore + var body: some View { + Button { store.showConnection = true } label: { + HStack(spacing: 5) { + Circle().fill(store.connected && store.syncIsFresh ? RideStyle.green : Color.gray).frame(width: 6, height: 6) + Text(store.connected ? "Live demo · \(store.role.title)" : "Connect two-phone demo") + Image(systemName: "slider.horizontal.3") + }.font(.system(size: 11, weight: .medium)).foregroundStyle(.secondary) + }.accessibilityIdentifier("demo.connection") + } +} + +struct RideLocationRow: View { + let place: DemoPlace + var icon = "mappin.circle.fill" + var body: some View { + HStack(spacing: 14) { + Image(systemName: icon).font(.system(size: 20)).frame(width: 28) + VStack(alignment: .leading, spacing: 4) { + Text(place.name).font(.system(size: 16, weight: .medium)).foregroundStyle(.black).lineLimit(2) + Text(place.address).font(.system(size: 13)).foregroundStyle(.secondary).lineLimit(2) + } + Spacer(minLength: 0) + }.padding(.vertical, 12).contentShape(Rectangle()) + } +} + +struct RideRouteSummary: View { + let pickup: DemoPlace + let destination: DemoPlace + var stops: [DemoPlace] = [] + var body: some View { + VStack(alignment: .leading, spacing: 0) { + RideLocationRow(place: pickup, icon: "circle.inset.filled") + ForEach(stops) { RideLocationRow(place: $0, icon: "circle") } + RideLocationRow(place: destination, icon: "square.fill") + } + } +} + +struct RideInfoView: View { + let title: String + let icon: String + let text: String + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + Image(systemName: icon).font(.system(size: 44)).padding(.top, 20) + Text(title).font(.largeTitle.bold()) + Text(text).font(.body).foregroundStyle(.secondary) + Text("Independent demo. No real trips, payments, calls, or emergency services are provided.").font(.footnote).padding(16).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 12)) + }.frame(maxWidth: .infinity, alignment: .leading).padding(20) + }.navigationTitle(title).navigationBarTitleDisplayMode(.inline) + } +} diff --git a/apps/rides/Features/RiderHomeView.swift b/apps/rides/Features/RiderHomeView.swift new file mode 100644 index 0000000..0ff5d35 --- /dev/null +++ b/apps/rides/Features/RiderHomeView.swift @@ -0,0 +1,121 @@ +import SwiftUI + +struct RiderHomeView: View { + @EnvironmentObject private var store: RideStore + @State private var eatsInfo = false + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + HStack(alignment: .firstTextBaseline) { + Text("Uber").font(.system(size: 38, weight: .medium)).tracking(-2) + Text("DEMO").font(.system(size: 9, weight: .bold)).tracking(1).padding(.horizontal, 7).padding(.vertical, 4).background(RideStyle.soft).clipShape(Capsule()) + Spacer() + DemoConnectionBadge() + }.padding(.top, 6) + HStack(spacing: 0) { + VStack(spacing: 12) { + Label("Rides", systemImage: "car.side.fill").font(.system(size: 18, weight: .semibold)) + Rectangle().frame(height: 3) + }.frame(maxWidth: .infinity) + Button { + if let url = URL(string: "eatsclone://home"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url) } + else { eatsInfo = true } + } label: { + VStack(spacing: 12) { + Label("Eats", systemImage: "takeoutbag.and.cup.and.straw.fill").font(.system(size: 18, weight: .semibold)) + Color.clear.frame(height: 3) + }.frame(maxWidth: .infinity) + }.foregroundStyle(.secondary) + } + HStack(spacing: 10) { + Button { store.beginBooking() } label: { + HStack(spacing: 12) { Image(systemName: "magnifyingglass"); Text("Where to?").fontWeight(.semibold); Spacer() } + }.accessibilityIdentifier("rider.whereTo") + Button { store.beginBooking(reserve: true) } label: { + HStack(spacing: 5) { Image(systemName: "clock.fill"); Text("Now"); Image(systemName: "chevron.down").font(.system(size: 10)) }.font(.system(size: 13, weight: .semibold)).padding(.horizontal, 11).padding(.vertical, 10).background(.white).clipShape(Capsule()) + } + }.padding(14).background(RideStyle.soft).clipShape(Capsule()).foregroundStyle(.black) + VStack(spacing: 0) { + ForEach(Array(RideCatalog.places.dropFirst().prefix(2))) { place in + Button { store.beginBooking(destination: place) } label: { RideLocationRow(place: place, icon: "clock.fill") } + Divider().padding(.leading, 42) + } + }.padding(.top, -12) + VStack(alignment: .leading, spacing: 15) { + HStack { Text("Suggestions").font(.system(size: 22, weight: .bold)); Spacer(); Button("See all") { store.tab = 1 }.font(.system(size: 13, weight: .medium)) } + HStack(spacing: 10) { + suggestion("Ride", icon: "car.side.fill") { store.beginBooking() } + suggestion("Reserve", icon: "calendar.badge.clock") { store.beginBooking(reserve: true) } + suggestion("Comfort", icon: "car.side.hill.up.fill") { store.beginBooking(); store.draft.service = .comfort } + suggestion("Saved", icon: "star.fill") { store.tab = 3 } + } + } + Button { store.beginBooking(reserve: true) } label: { + HStack { + VStack(alignment: .leading, spacing: 13) { + Text("Go on your time").font(.system(size: 24, weight: .bold)) + Text("Reserve your ride ahead.\nOne less thing to think about.").font(.system(size: 14)).multilineTextAlignment(.leading) + Label("Explore Reserve", systemImage: "arrow.right").font(.system(size: 13, weight: .semibold)).labelStyle(.titleAndIcon) + } + Spacer(minLength: 0) + Image(systemName: "calendar").font(.system(size: 68, weight: .thin)).rotationEffect(.degrees(-12)) + }.padding(22).frame(maxWidth: .infinity, alignment: .leading).background(Color(red: 0.86, green: 0.91, blue: 0.96)).clipShape(RoundedRectangle(cornerRadius: 14)) + }.foregroundStyle(.black) + VStack(alignment: .leading, spacing: 13) { + Text("A little comfort goes a long way").font(.system(size: 22, weight: .bold)) + HStack { + RideCarArt(service: .comfort) + VStack(alignment: .leading, spacing: 6) { Text("Room to relax").font(.headline); Text("Extra legroom. A quieter ride.").font(.subheadline).foregroundStyle(.secondary) } + Spacer() + }.padding(18).frame(maxWidth: .infinity, alignment: .leading).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 12)) + } + Text("Independent ride demo · No real rides or charges").font(.caption2).foregroundStyle(.secondary).padding(.bottom, 12) + }.padding(.horizontal, 20) + }.background(.white) + .alert("Eats is a separate app", isPresented: $eatsInfo) { Button("OK", role: .cancel) {} } message: { Text("The Eats demo is unchanged in this repository. Install the EatsClone target to switch to it from here.") } + } + + private func suggestion(_ title: String, icon: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 10) { + Image(systemName: icon).font(.system(size: 28, weight: .regular)).frame(height: 34) + Text(title).font(.system(size: 11, weight: .medium)) + }.frame(maxWidth: .infinity).padding(.vertical, 15).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 11)) + }.foregroundStyle(.black) + } +} + +struct RideServicesView: View { + @EnvironmentObject private var store: RideStore + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + Text(store.role == .driver ? "Earnings" : "Services").font(.system(size: 32, weight: .bold)).padding(.top, 12) + if store.role == .driver { + Text("Your demo earnings").font(.headline) + Text(RideCatalog.money(store.earningsCents)).font(.system(size: 46, weight: .bold)) + Text("Completed rides assigned to you, including demo tips. No money is transferred.").foregroundStyle(.secondary) + ForEach(store.pastTrips.filter { $0.status == .completed && $0.driver?.id == store.credentials?.participantId }) { trip in + HStack { VStack(alignment: .leading) { Text(trip.destination.name).font(.headline); Text(RideCatalog.date(trip.createdAt)).font(.caption).foregroundStyle(.secondary) }; Spacer(); Text(RideCatalog.money(trip.totalCents)).fontWeight(.semibold) }.padding(.vertical, 10) + Divider() + } + } else { + Text("Go anywhere").font(.title3.bold()) + LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: 14) { + ForEach(RideService.allCases) { service in + Button { store.beginBooking(); store.draft.service = service } label: { + VStack(alignment: .leading, spacing: 12) { + HStack { Spacer(); RideCarArt(service: service) } + Text(service.title).font(.headline) + Text(service.detail).font(.caption).foregroundStyle(.secondary).frame(height: 32, alignment: .top) + }.frame(maxWidth: .infinity, alignment: .leading).padding(16).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 12)) + }.foregroundStyle(.black) + } + } + Button { store.beginBooking(reserve: true) } label: { Label("Reserve a ride", systemImage: "calendar.badge.clock").font(.headline).frame(maxWidth: .infinity, alignment: .leading).padding(20).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 12)) }.foregroundStyle(.black) + Text("Bikes, rentals, group invitations, and deliveries aren't part of this ride demo.").font(.footnote).foregroundStyle(.secondary) + } + }.padding(20) + }.navigationBarTitleDisplayMode(.inline) + } +} diff --git a/apps/rides/Features/TripViews.swift b/apps/rides/Features/TripViews.swift new file mode 100644 index 0000000..af5b258 --- /dev/null +++ b/apps/rides/Features/TripViews.swift @@ -0,0 +1,275 @@ +import SwiftUI + +struct TripTrackingView: View { + @EnvironmentObject private var store: RideStore + let tripID: String + @State private var showCancel = false + @State private var showCall = false + var body: some View { + if let trip = store.state?.trips.first(where: { $0.id == tripID }) { + if trip.status == .completed { RideReceiptView(tripID: tripID) } + else { + GeometryReader { geometry in + VStack(spacing: 0) { + ZStack(alignment: .topTrailing) { + DemoMapView(status: trip.status) + NavigationLink { RideSafetyView(trip: trip) } label: { Image(systemName: "shield.lefthalf.filled").font(.system(size: 20)).foregroundStyle(.blue).frame(width: 46, height: 46).background(.white).clipShape(Circle()) }.padding(18) + }.frame(height: geometry.size.height * 0.43) + ScrollView { + VStack(alignment: .leading, spacing: 18) { + Capsule().fill(.gray.opacity(0.3)).frame(width: 38, height: 4).frame(maxWidth: .infinity) + HStack { Text(trip.status.title).font(.system(size: 24, weight: .bold)); Spacer() } + DemoConnectionBadge() + if trip.status == .requested { + HStack(spacing: 12) { ProgressView(); Text(store.state?.driverOnline == true ? "Waiting for the driver phone to accept." : "Your demo driver is offline. Go online on the other phone to accept this ride.").font(.subheadline).foregroundStyle(.secondary) } + Text("The request is shared with your demo room. It won't be accepted automatically.").font(.caption).foregroundStyle(.secondary) + } + if trip.status == .cancelled { + Text(trip.cancellationReason ?? "Plans changed. No fee was charged.").font(.subheadline).foregroundStyle(.secondary) + Text("No cancellation fee. No payment was collected.").font(.subheadline).foregroundStyle(.secondary) + RidePrimaryButton(title: "Back to Home") { store.focusedTripID = nil; store.tab = 0 } + } + if let driver = trip.driver { + HStack(alignment: .center, spacing: 14) { + ZStack { Circle().fill(RideStyle.soft); Image(systemName: "person.fill").font(.system(size: 30)).foregroundStyle(.gray) }.frame(width: 58, height: 58) + VStack(alignment: .leading, spacing: 5) { + Text(driver.name).font(.headline) + Label(String(format: "%.2f", driver.rating), systemImage: "star.fill").font(.caption) + } + Spacer() + VStack(alignment: .trailing, spacing: 5) { + Text(driver.plate).font(.system(size: 20, weight: .bold)).padding(.horizontal, 8).padding(.vertical, 5).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 5)) + Text(driver.vehicle).font(.caption).foregroundStyle(.secondary) + } + } + if trip.canMessage { + HStack(spacing: 12) { + NavigationLink { RideChatView(tripID: trip.id) } label: { Label("Message driver", systemImage: "message.fill").font(.subheadline.bold()).frame(maxWidth: .infinity).padding(15).background(RideStyle.soft).clipShape(Capsule()) } + Button { showCall = true } label: { Image(systemName: "phone.fill").frame(width: 48, height: 48).background(RideStyle.soft).clipShape(Circle()) }.accessibilityLabel("Demo call driver") + }.foregroundStyle(.black) + } + } + if trip.status.isActive { + VStack(alignment: .leading, spacing: 10) { + Text(trip.status == .inProgress ? "Enjoy the ride" : "Meet at your pickup spot").font(.headline) + Text(trip.status == .inProgress ? trip.destination.name : trip.pickup.name).font(.subheadline) + Text(trip.status == .inProgress ? "Your demo driver will complete the ride from the other phone." : "Your demo driver will mark arrival and start the trip from the other phone.").font(.caption).foregroundStyle(.secondary) + }.padding(16).frame(maxWidth: .infinity, alignment: .leading).overlay { RoundedRectangle(cornerRadius: 12).stroke(.gray.opacity(0.22)) } + } + RideRouteSummary(pickup: trip.pickup, destination: trip.destination, stops: trip.stops) + HStack { Text(trip.serviceTitle); Spacer(); Text(RideCatalog.money(trip.status == .cancelled ? 0 : trip.fareCents)).fontWeight(.semibold) }.font(.subheadline) + if trip.status.isActive, store.rideReminders, let date = trip.scheduledAt { Label("Reservation: \(RideCatalog.date(date))", systemImage: "calendar").font(.caption) } + if !trip.note.isEmpty { Label(trip.note, systemImage: "text.bubble").font(.caption) } + if trip.status.canCancel { Button("Cancel ride", role: .destructive) { showCancel = true }.font(.subheadline.bold()).frame(maxWidth: .infinity).padding(.vertical, 10).disabled(store.busy) } + Text("Simulated map and travel times · No live GPS or real driver").font(.system(size: 10)).foregroundStyle(.secondary) + }.padding(20) + } + } + }.sheet(isPresented: $showCancel) { CancelRideView(trip: trip).environmentObject(store) } + .alert("Demo call", isPresented: $showCall) { Button("OK", role: .cancel) {} } message: { Text("No phone call is placed. Use Message driver to send a real message between the two demo phones.") } + } + } else { ContentUnavailableView("Ride not available", systemImage: "car.side", description: Text("Reconnect to the demo server to refresh this ride.")) } + } +} + +struct DriverHomeView: View { + @EnvironmentObject private var store: RideStore + @State private var showCancel = false + var body: some View { + VStack(spacing: 0) { + ZStack(alignment: .top) { + DemoMapView(route: store.myDriverTrip != nil, status: store.activeTrip?.status ?? .requested) + VStack(spacing: 12) { + HStack { + Button { store.showConnection = true } label: { Image(systemName: "line.3.horizontal").font(.system(size: 20)).frame(width: 44, height: 44).background(.white).clipShape(Circle()) } + Spacer() + Button { store.tab = 1 } label: { Text(RideCatalog.money(store.earningsCents)).font(.system(size: 22, weight: .bold)).padding(.horizontal, 22).padding(.vertical, 11).background(.black).foregroundStyle(.white).clipShape(Capsule()) } + Spacer() + Image(systemName: "shield.lefthalf.filled").font(.system(size: 20)).frame(width: 44, height: 44).background(.white).clipShape(Circle()).accessibilityHidden(true) + } + DemoConnectionBadge().padding(8).background(.white.opacity(0.94)).clipShape(Capsule()) + Spacer() + if !store.isDriverOnline && store.myDriverTrip == nil { + Button { + if store.connected { Task { await store.setOnline(true) } } + else { store.showConnection = true } + } label: { + Text("GO").font(.system(size: 27, weight: .bold)).frame(width: 84, height: 84).background(Color(red: 0.15, green: 0.42, blue: 0.96)).foregroundStyle(.white).clipShape(Circle()).overlay { Circle().stroke(.white, lineWidth: 5).padding(5) }.shadow(color: .black.opacity(0.18), radius: 8, y: 3) + }.padding(.bottom, 22).disabled(store.busy).accessibilityIdentifier("driver.goOnline") + } + }.padding(18) + }.frame(minHeight: 220, maxHeight: .infinity) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Capsule().fill(.gray.opacity(0.25)).frame(width: 38, height: 4).frame(maxWidth: .infinity) + if let trip = store.myDriverTrip { + Text(trip.status == .accepted ? "Head to pickup" : trip.status == .arriving ? "Ready to start?" : "Drive to the destination").font(.system(size: 25, weight: .bold)) + HStack { Label(trip.riderName, systemImage: "person.fill").font(.headline); Spacer(); Text(trip.serviceTitle).font(.subheadline) } + RideRouteSummary(pickup: trip.pickup, destination: trip.destination, stops: trip.stops) + if !trip.note.isEmpty { Label(trip.note, systemImage: "text.bubble").font(.subheadline) } + NavigationLink { RideChatView(tripID: trip.id) } label: { Label("Message rider", systemImage: "message.fill").font(.subheadline.bold()).frame(maxWidth: .infinity).padding(14).background(RideStyle.soft).clipShape(Capsule()) }.foregroundStyle(.black) + RidePrimaryButton(title: trip.status == .accepted ? "I've arrived" : trip.status == .arriving ? "Start \(trip.serviceTitle)" : "Complete ride", busy: store.busy) { + Task { await store.act(trip, RideAction(action: trip.status == .accepted ? "arrive" : trip.status == .arriving ? "start" : "complete")) } + } + if trip.status.canCancel { Button("Cancel ride", role: .destructive) { showCancel = true }.font(.caption.bold()).frame(maxWidth: .infinity) } + } else if let trip = store.activeTrip, trip.status == .requested, store.isDriverOnline { + HStack { Label("New ride request", systemImage: "person.fill").font(.headline); Spacer(); Text(trip.serviceTitle).font(.caption.bold()).padding(7).background(RideStyle.soft).clipShape(Capsule()) } + Text(RideCatalog.money(trip.fareCents)).font(.system(size: 42, weight: .bold)) + Text(trip.riderName).font(.headline) + RideRouteSummary(pickup: trip.pickup, destination: trip.destination, stops: trip.stops) + if let date = trip.scheduledAt { Label("Reserved: \(RideCatalog.date(date))", systemImage: "calendar").font(.caption) } + RidePrimaryButton(title: "Accept ride", busy: store.busy) { Task { await store.act(trip, RideAction(action: "accept")) } } + .accessibilityIdentifier("driver.acceptRide") + Text("Accepting here updates the rider phone. No real trip is dispatched.").font(.caption).foregroundStyle(.secondary) + } else { + HStack { + Text(store.isDriverOnline ? "You're online" : "You're offline").font(.system(size: 26, weight: .bold)) + Spacer() + if store.isDriverOnline { Button("Go offline") { Task { await store.setOnline(false) } }.font(.subheadline.bold()).disabled(store.busy) } + } + Text(store.isDriverOnline ? store.activeTrip == nil ? "Waiting for a request from the rider phone." : "Another driver accepted this ride." : "Go online to receive ride requests from your demo room.").font(.subheadline).foregroundStyle(.secondary) + if !store.connected { RidePrimaryButton(title: "Join a rider's demo room") { store.showConnection = true } } + HStack { Image(systemName: "checkmark.shield.fill").foregroundStyle(RideStyle.green); Text("Driver companion demo").font(.caption.bold()); Spacer(); Text("Austin, TX").font(.caption).foregroundStyle(.secondary) }.padding(.top, 8) + } + }.padding(20) + }.frame(maxHeight: store.myDriverTrip != nil || (store.activeTrip?.status == .requested && store.isDriverOnline) ? 430 : 200) + }.background(.white).sheet(isPresented: $showCancel) { if let trip = store.myDriverTrip { CancelRideView(trip: trip).environmentObject(store) } } + } +} + +struct CancelRideView: View { + @EnvironmentObject private var store: RideStore + @Environment(\.dismiss) private var dismiss + let trip: DemoTrip + @State private var reason = "Changed my mind" + var body: some View { + NavigationStack { + VStack(alignment: .leading, spacing: 22) { + Text(trip.scheduledAt == nil ? "Cancel your ride?" : "Cancel your reservation?").font(.system(size: 29, weight: .bold)) + Text("You won't be charged a cancellation fee. The other phone will see the ride cancelled.").foregroundStyle(.secondary) + Picker("Reason", selection: $reason) { + ForEach(["Changed my mind", "Pickup location is wrong", "Driver is too far away", "Other"], id: \.self) { Text($0).tag($0) } + }.pickerStyle(.inline) + if let error = store.errorMessage { Text(error).font(.caption).foregroundStyle(.red) } + RidePrimaryButton(title: "Keep ride") { dismiss() } + Button("Cancel ride", role: .destructive) { + Task { if await store.act(trip, RideAction(action: "cancel", reason: reason)) { dismiss() } } + }.font(.headline).frame(maxWidth: .infinity).padding(10).disabled(store.busy) + Spacer() + }.padding(24).navigationTitle("Your ride").navigationBarTitleDisplayMode(.inline).toolbar { ToolbarItem(placement: .topBarTrailing) { Button("Done") { dismiss() } } } + }.tint(.black).presentationDetents([.large]) + } +} + +struct RideChatView: View { + @EnvironmentObject private var store: RideStore + let tripID: String + @State private var message = "" + var body: some View { + let trip = store.state?.trips.first { $0.id == tripID } + VStack(spacing: 0) { + Text("Keep your account safe. Never share personal, payment, or account information in this demo chat.").font(.caption).foregroundStyle(.secondary).padding(14).frame(maxWidth: .infinity).background(RideStyle.soft) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 14) { + ForEach(trip?.messages ?? []) { message in + HStack { + if message.senderId == store.credentials?.participantId { Spacer(minLength: 35) } + VStack(alignment: .leading, spacing: 5) { + Text(message.senderName).font(.caption.bold()) + Text(message.text).font(.body) + }.padding(14).background(message.senderId == store.credentials?.participantId ? Color.black : RideStyle.soft).foregroundStyle(message.senderId == store.credentials?.participantId ? .white : .black).clipShape(RoundedRectangle(cornerRadius: 16)) + if message.senderId != store.credentials?.participantId { Spacer(minLength: 35) } + }.id(message.id) + } + }.padding(20) + }.onChange(of: trip?.messages.count) { _, _ in if let last = trip?.messages.last { withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } } } + } + if trip?.canMessage == true { + ScrollView(.horizontal, showsIndicators: false) { + HStack { ForEach(["I'm here", "Be right there", "I'm looking for you"], id: \.self) { text in Button(text) { send(text, trip: trip) }.font(.caption.bold()).padding(12).background(RideStyle.soft).clipShape(Capsule()).disabled(store.busy) } }.padding(.horizontal, 18) + } + HStack { + TextField("Message", text: $message, axis: .vertical).lineLimit(1...4).padding(13).background(RideStyle.soft).clipShape(Capsule()).accessibilityIdentifier("chat.message") + Button { send(message, trip: trip) } label: { Image(systemName: "arrow.up").font(.headline).frame(width: 44, height: 44).background(.black).foregroundStyle(.white).clipShape(Circle()) }.disabled(store.busy || message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.count > 500).accessibilityLabel("Send message") + }.padding(18) + } else { Text("This ride has ended. Chat is closed.").font(.subheadline).foregroundStyle(.secondary).padding(20) } + if let error = store.errorMessage { Text(error).font(.caption).foregroundStyle(.red).padding(.horizontal, 18) } + }.navigationTitle(store.role == .rider ? trip?.driver?.name ?? "Driver" : trip?.riderName ?? "Rider").navigationBarTitleDisplayMode(.inline).toolbar(.visible, for: .navigationBar).tint(.black) + } + private func send(_ text: String, trip: DemoTrip?) { + guard let trip else { return } + Task { if await store.act(trip, RideAction(action: "message", text: text)) { message = "" } } + } +} + +struct RideReceiptView: View { + @EnvironmentObject private var store: RideStore + @Environment(\.dismiss) private var dismiss + let tripID: String + @State private var rating = 0 + @State private var tip = 0 + @State private var editing = false + var body: some View { + if let trip = store.state?.trips.first(where: { $0.id == tripID }) { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + HStack { Spacer(); Image(systemName: "checkmark.circle.fill").font(.system(size: 52)).foregroundStyle(RideStyle.green); Spacer() }.padding(.top, 22) + Text("Thanks for riding").font(.system(size: 30, weight: .bold)).frame(maxWidth: .infinity) + Text("Your trip was \(RideCatalog.money(trip.fareCents))").font(.subheadline).foregroundStyle(.secondary).frame(maxWidth: .infinity) + if store.role == .rider && (trip.rating == nil || editing) { + Text("Add a tip for \(trip.driver?.name ?? "your driver")").font(.headline).frame(maxWidth: .infinity) + HStack(spacing: 10) { + ForEach([0, 100, 300, 500], id: \.self) { amount in + Button(amount == 0 ? "No tip" : RideCatalog.money(amount)) { tip = amount }.font(.subheadline.bold()).frame(maxWidth: .infinity).padding(.vertical, 12).background(tip == amount ? .black : RideStyle.soft).foregroundStyle(tip == amount ? .white : .black).clipShape(Capsule()) + } + } + Text("Rate your trip").font(.headline).frame(maxWidth: .infinity) + HStack(spacing: 18) { + ForEach(1...5, id: \.self) { value in + Button { rating = value } label: { Image(systemName: value <= rating ? "star.fill" : "star").font(.system(size: 33)).foregroundStyle(.black) }.accessibilityLabel("\(value) star\(value == 1 ? "" : "s")") + } + }.frame(maxWidth: .infinity) + RidePrimaryButton(title: "Submit rating", busy: store.busy, disabled: rating == 0) { + Task { if await store.act(trip, RideAction(action: "rate", rating: rating, tipCents: tip)) { editing = false; store.focusedTripID = nil; store.tab = 2; dismiss() } } + } + Button("Skip for now") { store.focusedTripID = nil; store.tab = 2; dismiss() }.font(.subheadline).frame(maxWidth: .infinity) + } + DemoMapView().frame(height: 130).clipShape(RoundedRectangle(cornerRadius: 12)) + RideRouteSummary(pickup: trip.pickup, destination: trip.destination, stops: trip.stops) + Divider() + HStack { Text("Trip fare"); Spacer(); Text(RideCatalog.money(trip.fareCents)) } + HStack { Text("Tip"); Spacer(); Text(RideCatalog.money(trip.tipCents)) } + HStack { Text("Total").font(.headline); Spacer(); Text(RideCatalog.money(trip.totalCents)).font(.title2.bold()) } + Label(trip.paymentMethod, systemImage: "creditcard.fill").font(.subheadline) + if let rating = trip.rating { + Label("Your rating: \(rating) of 5", systemImage: "star.fill").font(.subheadline) + if store.role == .rider { Button("Edit rating and tip") { self.rating = rating; tip = trip.tipCents; editing = true } } + } + Text("\(RideCatalog.date(trip.createdAt)) · \(trip.serviceTitle)\nTrip \(trip.shortID)").font(.caption).foregroundStyle(.secondary) + Text("Demo receipt. No money was transferred.").font(.caption).foregroundStyle(.secondary) + if let error = store.errorMessage { Text(error).font(.caption).foregroundStyle(.red) } + if store.role == .rider && trip.rating != nil { + RidePrimaryButton(title: "Ride again") { store.beginBooking(destination: trip.destination); store.draft.pickup = trip.pickup; store.draft.stops = trip.stops; store.draft.service = RideService(rawValue: trip.service) ?? .uberX } + } + }.padding(20) + }.navigationTitle("Trip details").navigationBarTitleDisplayMode(.inline).toolbar(.visible, for: .navigationBar).tint(.black) + } + } +} + +struct RideSafetyView: View { + let trip: DemoTrip + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + Image(systemName: "shield.lefthalf.filled").font(.system(size: 48)).foregroundStyle(.blue) + Text("Safety toolkit").font(.system(size: 30, weight: .bold)) + Text("This is a demo, not an emergency service.").font(.headline) + Text("If you need real emergency assistance, contact your local emergency number outside this app. The demo does not call emergency services, record audio, monitor crashes, or track your location.").foregroundStyle(.secondary) + ShareLink(item: "Demo ride \(trip.shortID)\n\(trip.pickup.name) → \(trip.destination.name)\nStatus: \(trip.status.title)\nThis is a simulated ride, not live location sharing.") { Label("Share demo trip details", systemImage: "square.and.arrow.up").font(.headline).padding(18).frame(maxWidth: .infinity).background(RideStyle.soft).clipShape(RoundedRectangle(cornerRadius: 12)) } + Text("Before a real ride, verify the driver's photo, vehicle, and plate. No PIN verification or safety monitoring is implemented in this demo.").font(.subheadline).foregroundStyle(.secondary) + }.padding(24) + }.navigationTitle("Safety").navigationBarTitleDisplayMode(.inline).toolbar(.visible, for: .navigationBar).tint(.black) + } +} diff --git a/apps/rides/Package.swift b/apps/rides/Package.swift new file mode 100644 index 0000000..7671b42 --- /dev/null +++ b/apps/rides/Package.swift @@ -0,0 +1,12 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "RidesDemoCore", + platforms: [.macOS(.v14), .iOS(.v17)], + products: [.library(name: "RidesDemo", targets: ["RidesDemo"])], + targets: [ + .target(name: "RidesDemo", path: "Core"), + .testTarget(name: "RidesDemoTests", dependencies: ["RidesDemo"], path: "Tests") + ] +) diff --git a/apps/rides/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png b/apps/rides/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png new file mode 100644 index 0000000..dcd11b3 Binary files /dev/null and b/apps/rides/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png differ diff --git a/apps/rides/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/rides/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..0a42994 --- /dev/null +++ b/apps/rides/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"filename":"AppIcon.png","idiom":"universal","platform":"ios","size":"1024x1024"}],"info":{"author":"xcode","version":1}} diff --git a/apps/rides/Resources/Assets.xcassets/Contents.json b/apps/rides/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..f223229 --- /dev/null +++ b/apps/rides/Resources/Assets.xcassets/Contents.json @@ -0,0 +1 @@ +{"info":{"author":"xcode","version":1}} diff --git a/apps/rides/Resources/PrivacyInfo.xcprivacy b/apps/rides/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..6c6976b --- /dev/null +++ b/apps/rides/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,12 @@ + + + +NSPrivacyTracking +NSPrivacyTrackingDomains +NSPrivacyAccessedAPITypesNSPrivacyAccessedAPITypeNSPrivacyAccessedAPICategoryUserDefaultsNSPrivacyAccessedAPITypeReasonsCA92.1 +NSPrivacyCollectedDataTypes +NSPrivacyCollectedDataTypeNSPrivacyCollectedDataTypeNameNSPrivacyCollectedDataTypeLinkedNSPrivacyCollectedDataTypeTrackingNSPrivacyCollectedDataTypePurposesNSPrivacyCollectedDataTypePurposeAppFunctionality +NSPrivacyCollectedDataTypeNSPrivacyCollectedDataTypeUserIDNSPrivacyCollectedDataTypeLinkedNSPrivacyCollectedDataTypeTrackingNSPrivacyCollectedDataTypePurposesNSPrivacyCollectedDataTypePurposeAppFunctionality +NSPrivacyCollectedDataTypeNSPrivacyCollectedDataTypeOtherUserContentNSPrivacyCollectedDataTypeLinkedNSPrivacyCollectedDataTypeTrackingNSPrivacyCollectedDataTypePurposesNSPrivacyCollectedDataTypePurposeAppFunctionality + + diff --git a/apps/rides/Simulator.entitlements b/apps/rides/Simulator.entitlements new file mode 100644 index 0000000..0f8e870 --- /dev/null +++ b/apps/rides/Simulator.entitlements @@ -0,0 +1,12 @@ + + + + + application-identifier + com.revyl.ridesdemo + keychain-access-groups + + com.revyl.ridesdemo + + + diff --git a/apps/rides/Tests/RideCoreTests.swift b/apps/rides/Tests/RideCoreTests.swift new file mode 100644 index 0000000..e507adf --- /dev/null +++ b/apps/rides/Tests/RideCoreTests.swift @@ -0,0 +1,197 @@ +import XCTest +@testable import RidesDemo + +final class RideCoreTests: XCTestCase { + func testDemoFaresUseIntegerCentsAndStopSurcharges() { + var draft = BookingDraft() + XCTAssertEqual(draft.fareCents, 1840) + draft.service = .comfort + draft.stops = [RideCatalog.places[4]] + XCTAssertEqual(draft.fareCents, 2790) + XCTAssertEqual(RideService.uberXL.seats, 6) + } + func testRouteRejectsMissingOrDuplicateLocations() { + var draft = BookingDraft() + XCTAssertNotNil(draft.validationError()) + draft.destination = draft.pickup + XCTAssertNotNil(draft.validationError()) + draft.destination = RideCatalog.places[1] + XCTAssertNil(draft.validationError()) + draft.stops = [draft.pickup] + XCTAssertNotNil(draft.validationError()) + draft.stops = [RideCatalog.places[4], RideCatalog.places[4]] + XCTAssertNotNil(draft.validationError()) + } + func testSchedulingAndNoteValidation() { + var draft = BookingDraft() + draft.destination = RideCatalog.places[1] + let now = Date() + draft.scheduledAt = now.addingTimeInterval(-1) + XCTAssertNotNil(draft.validationError(now: now)) + draft.scheduledAt = now.addingTimeInterval(31 * 86400) + XCTAssertNotNil(draft.validationError(now: now)) + draft.scheduledAt = now.addingTimeInterval(3600) + XCTAssertNil(draft.validationError(now: now)) + draft.note = String(repeating: "x", count: 301) + XCTAssertNotNil(draft.validationError(now: now)) + } + func testBookingEncodesTheSharedServerContract() throws { + var draft = BookingDraft() + draft.destination = RideCatalog.places[1] + draft.payment = .cash + let request = try TripRequest(draft: draft) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(request)) as? [String: Any]) + XCTAssertEqual(json["service"] as? String, "uberX") + XCTAssertEqual(json["paymentMethod"] as? String, "Demo Cash") + XCTAssertNotNil(UUID(uuidString: try XCTUnwrap(json["requestId"] as? String))) + XCTAssertNil(json["token"]) + XCTAssertNil(json["fareCents"]) + } + func testServerURLRequiresHTTPSWithoutEmbeddedCredentials() throws { + XCTAssertNoThrow(try RideAPIClient.validatedURL("https://demo.example.com")) + XCTAssertNoThrow(try RideAPIClient.validatedURL("http://127.0.0.1:8787")) + for value in ["http://demo.example.com", "https://user:password@demo.example.com", "https://demo.example.com?token=test", "https://demo.example.com/#fragment", "https://demo.example.com/private", "not a url"] { + XCTAssertThrowsError(try RideAPIClient.validatedURL(value), value) + } + } + func testSearchAndTerminalStateRules() { + XCTAssertEqual(RideCatalog.search("airport").first?.id, "airport") + XCTAssertTrue(RideCatalog.search("unlisted location").isEmpty) + XCTAssertFalse(TripStatus.completed.isActive) + XCTAssertFalse(TripStatus.cancelled.isActive) + XCTAssertFalse(TripStatus.inProgress.canCancel) + XCTAssertTrue(TripStatus.arriving.canCancel) + } +} + +final class RevylRideSignInTests: XCTestCase { + private let fixture = String(repeating: "fixture-", count: 6) + private func outcome(_ query: String, enabled: Bool = true, testBuild: Bool = true) -> RevylRideSignIn.Outcome { + RevylRideSignIn.validate(URL(string: "ridesdemo://revyl-auth?" + query)!, environment: ["RIDES_AUTH_BYPASS_ENABLED": enabled ? "true" : "false", "RIDES_AUTH_BYPASS_TOKEN": fixture], arguments: [], isTestBuild: testBuild) + } + func testAllowsOnlyRiderAndDriverWithKnownRoutes() { + XCTAssertEqual(outcome("token=\(fixture)&role=rider&redirect=/home"), .accepted(.rider, 0)) + XCTAssertEqual(outcome("token=\(fixture)&role=driver&redirect=/activity"), .accepted(.driver, 2)) + } + func testRejectsWrongTokenDisabledGateAndProduction() { + XCTAssertEqual(outcome("token=wrong&role=rider&redirect=/home"), .rejected("Invalid session token")) + XCTAssertEqual(outcome("token=\(fixture)&role=rider&redirect=/home", enabled: false), .rejected("Session gate is disabled")) + XCTAssertEqual(outcome("token=\(fixture)&role=rider&redirect=/home", testBuild: false), .rejected("Unavailable in production builds")) + } + func testRejectsUnknownDuplicateAndExtraParameters() { + XCTAssertEqual(outcome("token=\(fixture)&role=admin&redirect=/home"), .rejected("Role is not allowed")) + XCTAssertEqual(outcome("token=\(fixture)&role=rider&redirect=/admin"), .rejected("Redirect is not allowed")) + XCTAssertEqual(outcome("token=\(fixture)&role=rider&redirect=/home&role=driver"), .rejected("Invalid sign-in parameters")) + XCTAssertEqual(outcome("token=\(fixture)&role=rider&redirect=/home&extra=x"), .rejected("Invalid sign-in parameters")) + } + func testReadsNativeLaunchArgumentPairs() { + let result = RevylRideSignIn.validate(URL(string: "ridesdemo://revyl-auth?token=\(fixture)&role=driver&redirect=/home")!, environment: [:], arguments: ["RidesDemo", "-RIDES_AUTH_BYPASS_ENABLED", "true", "-RIDES_AUTH_BYPASS_TOKEN", fixture], isTestBuild: true) + XCTAssertEqual(result, .accepted(.driver, 0)) + } +} + +private final class MemoryRideVault: RideSessionVault { + var value: StoredRideSession? + func load() -> StoredRideSession? { value } + func save(_ session: StoredRideSession) { value = session } + func clear() { value = nil } +} + +private final class StubRideAPI: RideAPI { + let baseURL = URL(string: "https://demo.example.com")! + var current = RideRoomState(roomCode: "DEMOABCD", revision: 1, driverOnline: false, onlineDriverIds: [], trips: []) + var failure: Error? + func create(name: String) async throws -> RideSession { if let failure { throw failure }; return RideSession(roomCode: current.roomCode, participantId: "rider-fixture", token: "test-only-fixture", role: .rider, name: name, state: current) } + func join(code: String, role: DemoRole, name: String) async throws -> RideSession { if let failure { throw failure }; return RideSession(roomCode: current.roomCode, participantId: "driver-fixture", token: "driver-test-fixture", role: role, name: name, state: current) } + func state(token: String) async throws -> RideRoomState { if let failure { throw failure }; return current } + func availability(token: String, online: Bool) async throws -> RideRoomState { current = RideRoomState(roomCode: current.roomCode, revision: current.revision + 1, driverOnline: online, onlineDriverIds: online ? ["driver-fixture"] : [], trips: []); return current } + func book(token: String, request: TripRequest) async throws -> RideRoomState { current } + func act(token: String, tripID: String, action: RideAction) async throws -> RideRoomState { current } +} + +@MainActor +final class RideStoreTests: XCTestCase { + private var defaults: UserDefaults! + private var suite: String! + private var vault: MemoryRideVault! + private var api: StubRideAPI! + private var store: RideStore! + override func setUp() async throws { + suite = "RidesDemoTests.\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suite)! + vault = MemoryRideVault() + api = StubRideAPI() + store = RideStore(defaults: defaults, vault: vault, apiFactory: { [api = api!] _ in api }) + store.serverAddress = "https://demo.example.com" + } + override func tearDown() async throws { defaults.removePersistentDomain(forName: suite) } + #if os(iOS) + func testSimulatorKeychainCanSaveRestoreUpdateAndClearSession() async throws { + let secureVault = KeychainRideSessionVault(account: "test-\(UUID().uuidString)") + defer { secureVault.clear() } + let session = try await api.create(name: "Keychain fixture") + let stored = StoredRideSession(session, baseURL: api.baseURL) + try secureVault.save(stored) + XCTAssertEqual(secureVault.load()?.participantId, stored.participantId) + XCTAssertEqual(secureVault.load()?.token, stored.token) + try secureVault.save(stored) + XCTAssertEqual(secureVault.load()?.roomCode, stored.roomCode) + secureVault.clear() + XCTAssertNil(secureVault.load()) + } + #endif + func testConnectPersistsSessionInTheVaultNotUserDefaults() async { + await store.connect(createNew: true) + XCTAssertTrue(store.connected) + XCTAssertNotNil(vault.value) + XCTAssertNil(defaults.object(forKey: "token")) + XCTAssertEqual(store.state?.revision, 1) + let restored = RideStore(defaults: defaults, vault: vault, apiFactory: { [api = api!] _ in api }) + XCTAssertEqual(restored.credentials?.participantId, "rider-fixture") + } + func testDriverJoinsAndAvailabilityUsesItsParticipantID() async { + store.chooseRole(.driver) + store.roomInput = "DEMOABCD" + await store.connect(createNew: false) + XCTAssertEqual(store.role, .driver) + XCTAssertFalse(store.isDriverOnline) + await store.setOnline(true) + XCTAssertTrue(store.isDriverOnline) + await store.setOnline(false) + XCTAssertFalse(store.isDriverOnline) + } + func testOlderPollCannotOverwriteNewerState() async { + await store.connect(createNew: true) + api.current = RideRoomState(roomCode: "DEMOABCD", revision: 9, driverOnline: true, onlineDriverIds: ["other"], trips: []) + await store.sync() + api.current = RideRoomState(roomCode: "DEMOABCD", revision: 2, driverOnline: false, onlineDriverIds: [], trips: []) + await store.sync() + XCTAssertEqual(store.state?.revision, 9) + } + func testExpiredTokenClearsSecureSession() async { + await store.connect(createNew: true) + api.failure = RideAPIError(status: 401, message: "Expired") + await store.sync() + XCTAssertFalse(store.connected) + XCTAssertNil(vault.value) + XCTAssertTrue(store.showConnection) + } + func testNetworkFailureKeepsLastSyncedStateAndSession() async { + await store.connect(createNew: true) + api.failure = URLError(.notConnectedToInternet) + await store.sync() + XCTAssertTrue(store.connected) + XCTAssertEqual(store.state?.revision, 1) + XCTAssertTrue(store.errorMessage?.hasPrefix("Connection lost.") == true) + } + func testSavedPlacesAndDefaultPaymentPersist() { + store.savePlace(RideCatalog.places[4]) + store.savePlace(RideCatalog.places[4]) + store.defaultPayment = .cash + let restored = RideStore(defaults: defaults, vault: vault) + XCTAssertEqual(restored.savedPlaces.filter { $0.id == "coffee" }.count, 1) + XCTAssertEqual(restored.defaultPayment, .cash) + restored.beginBooking() + XCTAssertEqual(restored.draft.payment, .cash) + } +} diff --git a/apps/rides/project.yml b/apps/rides/project.yml new file mode 100644 index 0000000..5b574be --- /dev/null +++ b/apps/rides/project.yml @@ -0,0 +1,58 @@ +name: RidesDemo +options: + deploymentTarget: + iOS: '17.0' +settings: + base: + SWIFT_VERSION: '5.0' + TARGETED_DEVICE_FAMILY: '1,2' + CODE_SIGNING_ALLOWED: YES + CODE_SIGN_IDENTITY: '-' + MARKETING_VERSION: '0.1.0' + CURRENT_PROJECT_VERSION: '1' +targets: + RidesDemo: + type: application + platform: iOS + info: + path: .generated/RidesDemo-Info.plist + properties: + CFBundleDisplayName: Rides + UILaunchScreen: {} + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: false + ITSAppUsesNonExemptEncryption: false + LSApplicationQueriesSchemes: [eatsclone] + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + CFBundleURLTypes: + - CFBundleURLName: com.revyl.ridesdemo.revyl + CFBundleURLSchemes: [ridesdemo] + sources: + - App + - Core + - Features + - Resources + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.revyl.ridesdemo + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + 'CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]': Simulator.entitlements + RidesDemoTests: + type: bundle.unit-test + platform: iOS + sources: [Tests] + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_BUNDLE_IDENTIFIER: com.revyl.ridesdemo.tests + dependencies: + - target: RidesDemo +schemes: + RidesDemo: + build: + targets: + RidesDemo: all + RidesDemoTests: [test] + test: + targets: [RidesDemoTests] diff --git a/apps/rides/scripts/build-ios.sh b/apps/rides/scripts/build-ios.sh new file mode 100755 index 0000000..2321c2a --- /dev/null +++ b/apps/rides/scripts/build-ios.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +if ! command -v xcodegen >/dev/null; then + brew install xcodegen +fi +swift test +swift test -c release +xcodegen generate +xcodebuild build-for-testing -project RidesDemo.xcodeproj -scheme RidesDemo -sdk iphonesimulator -destination 'generic/platform=iOS Simulator' -configuration Debug -derivedDataPath build CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- +python3 - <<'PY' +import plistlib +import subprocess +from pathlib import Path + +info = Path("build/Build/Products/Debug-iphonesimulator/RidesDemo.app/Info.plist") +with info.open("rb") as stream: + value = plistlib.load(stream) +schemes = [scheme for item in value.get("CFBundleURLTypes", []) for scheme in item.get("CFBundleURLSchemes", [])] +assert "ridesdemo" in schemes, "The built app is missing its Revyl URL scheme" +subprocess.run(["codesign", "--verify", str(info.parent)], check=True) +PY diff --git a/apps/rides/scripts/prepare-revyl-session.py b/apps/rides/scripts/prepare-revyl-session.py new file mode 100755 index 0000000..c9141fa --- /dev/null +++ b/apps/rides/scripts/prepare-revyl-session.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +import secrets + +print("RIDES_AUTH_BYPASS_ENABLED=true") +print("RIDES_AUTH_BYPASS_TOKEN=" + secrets.token_urlsafe(32)) diff --git a/apps/rides/scripts/start-session.py b/apps/rides/scripts/start-session.py new file mode 100644 index 0000000..7498518 --- /dev/null +++ b/apps/rides/scripts/start-session.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +import argparse +import json +import secrets +import subprocess +import sys +from pathlib import Path +from urllib.parse import urlencode, urlparse + +parser = argparse.ArgumentParser(description="Start an isolated Revyl rider or driver session without changing Eats defaults.") +parser.add_argument("--role", choices=["rider", "driver"], default="rider") +parser.add_argument("--build-version-id") +parser.add_argument("--server") +parser.add_argument("--room") +args = parser.parse_args() +if args.server: + parsed = urlparse(args.server) + if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ("", "/"): + parser.error("--server must be an HTTPS address without a path or credentials") +if args.room and (not args.server or not args.room.isascii() or not args.room.isalnum() or not 8 <= len(args.room) <= 32): + parser.error("--room needs --server and an 8–32 character invitation code") + +token = secrets.token_urlsafe(32) +link = "ridesdemo://revyl-auth?" + urlencode({"token": token, "role": args.role, "redirect": "/home"}) +command = [ + "revyl", "device", "start", "--platform", "ios", + "--app-id", "1f375e45-bf9b-434a-b939-a8892a2297c3", + "--launch-env", "RIDES_AUTH_BYPASS_ENABLED=true", + "--launch-env", "RIDES_AUTH_BYPASS_TOKEN=" + token, + "--app-link", link, "--timeout", "1800", "--open=false", "--json", +] +if args.build_version_id: + command += ["--build-version-id", args.build_version_id] +if args.server: + command += ["--launch-env", "RIDE_DEMO_BASE_URL=" + args.server] +if args.room: + command += ["--launch-env", "RIDE_DEMO_ROOM_CODE=" + args.room.upper()] + +root = Path(__file__).resolve().parents[1] +result = subprocess.run(command, cwd=root, capture_output=True, text=True) +if result.returncode: + print("Revyl could not start the rides session. Raw output is withheld because it may contain launch credentials. Inspect device availability with `revyl device list`.", file=sys.stderr) + raise SystemExit(result.returncode) +try: + payload = json.loads(result.stdout) +except json.JSONDecodeError: + print("Revyl started but returned unexpected output. Inspect `revyl device list`; raw output is withheld to protect launch credentials.", file=sys.stderr) + raise SystemExit(1) +safe = {key: payload[key] for key in ("session_id", "workflow_run_id", "index", "platform", "status", "device_model") if key in payload} +safe["role"] = args.role +print(json.dumps(safe, indent=2)) diff --git a/apps/rides/scripts/verify-ios.sh b/apps/rides/scripts/verify-ios.sh new file mode 100755 index 0000000..690f5d4 --- /dev/null +++ b/apps/rides/scripts/verify-ios.sh @@ -0,0 +1,6 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")/.." +bash scripts/build-ios.sh +DEVICE_ID=$(xcrun simctl list devices available -j | python3 -c 'import json,sys; device=next((d["udid"] for group in json.load(sys.stdin)["devices"].values() for d in group if "iPhone" in d["name"]), None); device or sys.exit("No available iPhone simulator. Install an iOS Simulator runtime in Xcode before running simulator tests."); print(device)') +xcodebuild test-without-building -project RidesDemo.xcodeproj -scheme RidesDemo -destination "platform=iOS Simulator,id=$DEVICE_ID" -configuration Debug -derivedDataPath build CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- diff --git a/docs/REVYL.md b/docs/REVYL.md index 0aa1e7a..2f5d3f4 100644 --- a/docs/REVYL.md +++ b/docs/REVYL.md @@ -4,6 +4,8 @@ The `pull-request` build profile runs the same `scripts/build-ios.sh` used for cloud compilation and core tests. Revyl builds a Debug iOS simulator app, then its proof agent exercises the changed behavior plus the declared ordering and navigation invariants. GitHub Actions independently runs `scripts/verify-ios.sh`. Neither path submits real orders or payments. +Rides has a separate Revyl project rooted at `apps/rides`, configured by `apps/rides/.revyl/config.yaml`. It has its own app ID, session hook, `rides` build profile, path triggers, and Proof of Changes instructions. The root config now contains only Eats. Use `revyl -C apps/rides config validate` and `revyl -C apps/rides build --profile rides --platform ios` for Rides, rather than selecting a Rides profile at the repository root. See the [Rides setup](RIDES.md) for the two-phone service requirements and proof boundaries. These are two Revyl project roots in one repository, not separate repositories or Capy projects. + The build also inspects the packaged app's URL schemes and fails if `eatsclone` is missing. XcodeGen writes its app plist under `.generated/`, separate from the `build/Info.plist` metadata that Xcode creates for Derived Data. ## Test-only session contract @@ -33,6 +35,12 @@ revyl github status --json The repository should report `access_granted: true` and project `status: enabled`. For a manually managed project, `revyl config push` publishes edits. Once the config is reconciled from the default branch, Git becomes authoritative: validate and commit subsequent configuration changes instead of manually publishing over it. +`Revyl setup` CI checks the configs select different projects and apps, recipes and session hooks resolve relative to their own roots, tokens are fresh and isolated, both proof policies are enabled, and runtime session files remain ignored. Run it locally with Python and `PyYAML==6.0.3` installed: + +```sh +python scripts/test_revyl_config.py +``` + After a preview build is ready, start a fresh session against its version: ```sh diff --git a/docs/RIDES.md b/docs/RIDES.md new file mode 100644 index 0000000..9f35ed4 --- /dev/null +++ b/docs/RIDES.md @@ -0,0 +1,79 @@ +# Native rides and paired-phone demo + +The repository contains two independent iOS 17+ SwiftUI applications. The original `EatsClone` target and its offline ordering behavior are unchanged. `apps/rides/project.yml` generates `RidesDemo.xcodeproj`, scheme `RidesDemo`, bundle `com.revyl.ridesdemo`, display name **Rides**. One Rides binary has rider and driver modes; install it on both phones. + +## Build profiles + +The apps share this Git repository, but each has its own Revyl project root, session setup, and automatic PR proof policy: + +| Config | Profile | Revyl app | Build command and output, relative to its project root | +| --- | --- | --- | --- | +| `.revyl/config.yaml` | `pull-request` | Eats, `ecced7bf-9a43-4a8b-9a26-c072fa7bad74` | `bash scripts/build-ios.sh` → `build/Build/Products/Debug-iphonesimulator/EatsClone.app` | +| `apps/rides/.revyl/config.yaml` | `rides` | Rides, `1f375e45-bf9b-434a-b939-a8892a2297c3` | `bash scripts/build-ios.sh` → `build/Build/Products/Debug-iphonesimulator/RidesDemo.app` | + +```sh +revyl config validate +revyl -C apps/rides config validate +revyl build --profile pull-request --platform ios +revyl -C apps/rides build --profile rides --platform ios +``` + +The root project remains Eats-only; run Rides CLI commands with `-C apps/rides` or from that directory. Revyl selects the nearest config, so each app gets its own sign-in rather than inheriting the Eats hook. Project `b13e33ff-5c8d-490e-bb24-9ef1d4c06bba` is registered at `apps/rides`, with independent preview builds, Revyl-harness Proof of Changes, strict build checks, and triggers for Rides and its shared service. Eats triggers cover its own source and configuration. A change touching both sets of paths is eligible for both projects. + +The Rides project was published in manual authority so automation can run before the config reaches `main`. Once Revyl reconciles its designated config from `main`, Git becomes authoritative. The existing root project is already Git-managed; its updated published aggregate follows `main`, while PR candidate configuration is validated at the exact PR head. Do not use a force-push of server configuration to bypass Git authority. + +Both proof policies explicitly scope device verification to their own app rather than expecting the other binary's screens. Rides automatic proof exercises independent Rider sign-in, offline booking through pickup confirmation, the connection-required boundary, tabs, saved places, and wallet. It deliberately does not claim to verify a two-phone trip without a reachable shared service and a second phone. Paired dispatch, chat, completion, and cancellation were verified separately on native phones. This setup does not deploy a permanent backend or establish full Uber parity. + +On a Mac, `cd apps/rides && bash scripts/build-ios.sh` runs the core tests in Debug and Release and builds the iOS app and test bundle. Simulator builds are ad-hoc signed with a simulator-specific Keychain identity; an unsigned simulator app cannot save the shared session. `bash scripts/verify-ios.sh` additionally executes XCTest, including a real Keychain round trip, on an installed iPhone simulator. The cloud build recipe compiles but does not execute iOS XCTest. The `Rides demo` GitHub workflow runs server tests and the simulator verification separately, on pull requests and pushes to `main`. + +## Shared service + +Two phones cannot synchronize through per-device stubs. `services/ride-demo` is a dependency-free Node 24 HTTP service with SQLite/WAL persistence. It stores isolated invitation rooms, per-participant bearer credentials (hashed server-side), demo requests, acceptance, transitions, messages, and ratings. It never dispatches a real car or processes a payment. See its [API and hosting documentation](../services/ride-demo/README.md). + +```sh +cd services/ride-demo +npm test +npm start +``` + +For persistent demos, run its Docker image behind HTTPS and mount `/data` on durable storage. Both phones need the same reachable HTTPS origin. A temporary workspace preview is useful for verification but is not permanent hosting: the workspace can sleep, the process can stop, and phones then show a connection error. No transient backend address or invitation is hardcoded in the app. + +## Pair two phones + +1. Install the same Rides build on both iPhones. On the rider, tap **Connect two-phone demo** or **Account → Rider & driver demo**. Choose **Rider**, enter the service's HTTPS address and a sample name, and tap **Create rider room**. +2. Keep the invitation private. On the second phone, choose **Driver**, enter the same service address and the rider's room code, and tap **Join as Driver**. Tap **GO** to become available. +3. On the rider, tap **Where to?**, select an offline demo destination, choose a service and payment label, then confirm pickup. The request must appear on the driver phone; nothing accepts it automatically. +4. Tap **Accept ride** on the driver. The rider must change to the assigned driver, vehicle, and plate. Use **Message driver/rider** to send messages between the phones. +5. The driver taps **I've arrived**, **Start**, and **Complete ride**. The rider observes each server-committed state after the next poll. The rider can submit a rating and demo tip; receipts and the driver's demo earnings update from shared state. +6. For a separate cancellation case, rebook and cancel before the trip starts. Both phones must reflect cancellation. No fee is charged. A ride cannot be cancelled after starting; the assigned driver completes it. + +Each room permits one active trip. Competing online drivers cannot both accept it. The client polls once per second while foregrounded and discards older room revisions. Network failures do not locally fake acceptance or advance a trip. Credentials are kept in Keychain; bookmarks, the default payment label, and in-app reminder preferences stay in UserDefaults. Driver identity persists with its saved session; a new driver join creates a different participant. + +## Isolated Revyl sign-in + +Each project owns its session hook. The root hook mints an Eats buyer token; `apps/rides/scripts/prepare-revyl-session.py` mints a fresh Rides token. Rides preview sessions without a checkout use the separate `RIDES_AUTH_BYPASS_ENABLED` and encrypted `RIDES_AUTH_BYPASS_TOKEN` organization launch references. They never replace the Eats variables. The automatic Rides proof defaults to Rider. The explicit launcher selects either Rider or Driver from the nested project: + +```sh +python3 apps/rides/scripts/start-session.py --role rider --server https://your-demo-server.example +python3 apps/rides/scripts/start-session.py --role driver --server https://your-demo-server.example +``` + +Pass `--build-version-id` to pin a build. Pass the rider's invitation with `--room` to join automatically. The launcher mints an independent token per phone, supplies it at app boot, and uses `ridesdemo://revyl-auth` with the requested allowlisted role. It captures raw CLI output and prints only safe session metadata, never the token or deep link. The fixed fresh-session names are Alex Morgan (rider) and Jordan Lee (driver). The bypass selects a demo role; it is not production authentication or a fixed global backend account. The handler is Debug-only, explicitly enabled and token-gated, with role, route, and parameter allowlists. Session creation can queue if the Revyl workspace has no capacity. + +## Reference coverage and limits + +The supplied [Uber iOS release](https://mobbin.com/apps/uber-ios-51d7d6f1-1a92-4d18-bb0c-0958f4d14598/d3962942-f984-494a-91bc-1d31f6e974e9/flows) redirects to a login page in this workspace's browser. The connected Mobbin search API supplied indexed Uber iOS flow metadata and screenshots; it did not provide an exhaustive version-pinned inventory. This implementation follows the core rider screen structure, not every Uber product or a claimed pixel-perfect reproduction. The driver dashboard is an original companion design, not a reproduction of Uber Driver screens. + +| Indexed reference | Demo implementation | +| --- | --- | +| [Booking a ride](https://mobbin.com/flows/c54d8dfb-8f67-4e82-afb6-7943d8886c5b) | Rides/Eats header, destination suggestions, pickup/destination, up to two stops, vehicle tiers, sample fares, payment selection, pickup confirmation, request and tracking states, rating and tips | +| [Reserving a ride](https://mobbin.com/flows/41341864-d3d9-4d31-b37e-840222b27239) | Date/time picker and reservation details; limited to 30 days; visible immediately to the driver and manually advanced, not timed dispatch | +| [Cancel reservation](https://mobbin.com/flows/7476e6b7-36ea-4a61-8da5-7fa55fcb41f0) | Reason selection and explicit keep/cancel actions before trip start; no real fee | +| [Message driver](https://mobbin.com/flows/c447156b-3263-43fb-8801-cb44fdf3beac) | Shared chat, quick replies, privacy warning; no VoIP, SMS, or automatic replies | +| [Activity](https://mobbin.com/flows/c8c8b75b-d49a-430a-9581-3bc669f3ce74) | Active/reserved ride, shared past trips, filters, receipt, and rebook | +| [Account](https://mobbin.com/flows/8b0e61f3-f4e6-4f64-bbf4-d9df7a247326), [Wallet](https://mobbin.com/flows/5d2557e7-3e74-4bfa-b5d0-ad40ef66f371), [Settings](https://mobbin.com/flows/56e8bd16-f5b3-41fc-af23-c5c7064ca6b6) | Demo profile, method selection, example balance, saved locations and preferences; no real card entry or balances | +| [Help](https://mobbin.com/flows/a7808204-ebf4-4030-8ae0-cb0d63b96806), [Safety toolkit](https://mobbin.com/flows/532bd6f4-1a97-4edf-8080-4bf347f3007b) | Demo help, trip detail sharing and explicit safety limitations; no live support, emergency calls, audio, crash detection, or PIN verification | + +Not implemented: exhaustive onboarding/auth parity, group invitations and contact switching, rentals, bikes/scooters, memberships, family/business profiles, promotions, real maps/geocoding/GPS, live dispatch or matching, payment settlement, and background/push delivery. Maps and all travel estimates are original offline illustrations. The Eats shortcut opens the separate installed Eats demo; if absent, it explains how to install it. No Mobbin screenshots, Uber logos, or proprietary fonts are bundled. + +See [verification evidence](RIDES_VERIFICATION.md) for checks actually completed and outstanding gates. diff --git a/docs/RIDES_VERIFICATION.md b/docs/RIDES_VERIFICATION.md new file mode 100644 index 0000000..7572b8c --- /dev/null +++ b/docs/RIDES_VERIFICATION.md @@ -0,0 +1,34 @@ +# Rides verification + +Verified on October 18, 2026. No real ride, charge, call, or location tracking is performed. + +## Automated checks + +- [Native simulator CI](https://github.com/RevylAI/uber-eats-clone/actions/runs/39191580033/job/117865343290) at `7c4fa9d` passed: 16 Swift core tests in Debug, 16 in Release, then **17 iOS XCTest cases**, including `testSimulatorKeychainCanSaveRestoreUpdateAndClearSession`. The log ends with `TEST EXECUTE SUCCEEDED`; this is executed simulator testing, not just `build-for-testing`. +- Six Node 24 HTTP integration tests pass. Coverage includes cross-client lifecycle, exclusive concurrent acceptance, idempotency, invitation/room isolation, assignment and role enforcement, chat/cancellation rules, schedule/route validation, and SQLite restart persistence. +- The repository-root `rides` build succeeded for app `1f375e45-bf9b-434a-b939-a8892a2297c3`: job `42e5b4e7-4482-4422-8e66-ce2909d27bc3`, version `94a524ee-0761-44a2-83de-f39cffeecc4f`. The recipe tests the core, compiles the app/test bundle, checks the packaged URL scheme, and verifies its ad-hoc signature. +- The original two-profile root configuration passed connected validation. Setup now uses independent project configs: root Eats and nested `apps/rides`. Both pass connected validation; the nested Rides project is published with independent PR automation, and the root remains Git-managed. Eats app code and buyer sign-in are unchanged. + +## Native paired-phone behavior + +Two fresh iOS sessions in the user's Revyl workspace installed the configured rides build above, rather than the separate Capy-managed build. The isolated launchers visibly accepted Rider and Driver sign-in. The following actions were performed in the actual native UI: + +1. The rider created a room and securely saved its session. The driver entered the private invitation, joined the same room, and went online. +2. The rider selected 701 Brazos Street → Austin–Bergstrom International Airport, chose UberX, and confirmed the $18.40 demo fare. The rider showed **Finding your ride** and the driver independently showed **New ride request**, Alex Morgan, matching route and fare. +3. The driver tapped **Accept ride**. Its screen changed to **Head to pickup**; the rider changed to **Your driver is on the way**, Jordan Lee, Toyota Prius, plate **DEMO 24**. +4. The rider sent **I'm here** and the driver sent **Be right there**. Both phones displayed both messages with the correct sender names. +5. The driver marked arrival and started the ride. The rider showed **Your driver has arrived**, then **You're on your way**. Driver completion opened the rider receipt and changed driver earnings to $18.40. +6. The rider submitted five stars and a $3.00 demo tip. Rider Activity showed $21.40, and the driver's earnings updated to $21.40. +7. The rider rebooked. The second request appeared on the driver, then disappeared after the rider confirmed cancellation. The rider showed **Your ride was cancelled** with its reason; driver Activity recorded **$0.00 · Cancelled** alongside the completed $21.40 trip. +8. An invalid-token native deep link was rejected with **Revyl sign-in rejected: Invalid session token**. The driver's existing role and trip history remained intact. + +The first unsigned build could reach the service but failed to save its session in Keychain. The build now uses simulator ad-hoc signing and a simulator-only entitlement file; the live Keychain XCTest and both native joins verify the fix. A final cancellation summary correction removes active pickup instructions and shows zero charges on cancelled trips. + +Screenshots of the actual native home, paired acceptance, shared chat, and receipt are attached to the pull request. Invitation codes, bearer tokens, launch gates, and signed artifact URLs are deliberately omitted from published evidence. + +## Limits + +- The verification server is a temporary HTTPS workspace preview. Persistent demos require a separately hosted instance with durable SQLite storage; restarting or sleeping the workspace is not permanent hosting. +- Disabled-gate, malformed-link, unauthorized-role/route, and production rejection are covered by core tests. Only accepted rider/driver roles and wrong-token rejection were additionally exercised on phones. +- The full version-pinned Uber reference inventory and exhaustive parity have not been established. The driver dashboard is an original companion, and several real Uber products/integrations are intentionally absent; see [scope and setup](RIDES.md). +- Automatic Revyl Proof of Changes is configured separately for Eats and Rides. Rides proof covers Rider sign-in, offline booking and navigation, and the required-connection boundary. A configured policy is not a completed proof, and this single-phone policy does not establish automated two-phone proof generation. diff --git a/scripts/test_revyl_config.py b/scripts/test_revyl_config.py new file mode 100644 index 0000000..be808e7 --- /dev/null +++ b/scripts/test_revyl_config.py @@ -0,0 +1,99 @@ +import contextlib +import io +import json +import os +from pathlib import Path +import runpy +import subprocess +import sys +import unittest +from unittest.mock import patch +from urllib.parse import parse_qs, urlparse + +import yaml + + +ROOT = Path(__file__).resolve().parents[1] + + +class RevylConfigTests(unittest.TestCase): + def setUp(self): + self.projects = [ROOT, ROOT / "apps/rides"] + self.configs = [yaml.safe_load((root / ".revyl/config.yaml").read_text()) for root in self.projects] + + def test_projects_and_apps_are_distinct(self): + self.assertNotEqual(self.configs[0]["project"]["id"], self.configs[1]["project"]["id"]) + apps = [] + for config in self.configs: + profiles = config["build"]["profiles"] + self.assertEqual(len(profiles), 1) + recipe = profiles[config["pr_review"]["build"]["profile"]]["ios"] + apps.append(recipe["app_id"]) + self.assertNotEqual(*apps) + + def test_build_paths_are_project_relative(self): + for root, config, bundle in zip(self.projects, self.configs, ["EatsClone.app", "RidesDemo.app"]): + recipe = next(iter(config["build"]["profiles"].values()))["ios"] + self.assertEqual(recipe["build_commands"], ["bash scripts/build-ios.sh"]) + self.assertTrue((root / "scripts/build-ios.sh").is_file()) + self.assertEqual(recipe["output_path"], "build/Build/Products/Debug-iphonesimulator/" + bundle) + + def test_session_hooks_are_isolated_and_mint_fresh_tokens(self): + for root, config, prefix, scheme, role in zip(self.projects, self.configs, ["EATSCLONE", "RIDES"], ["eatsclone", "ridesdemo"], ["buyer", "rider"]): + session = config["session"] + hook = root / session["before_script"]["script_path"] + self.assertTrue(os.access(hook, os.X_OK)) + values = [] + for _ in range(2): + result = subprocess.run([sys.executable, str(hook)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0) + value = dict(line.split("=", 1) for line in result.stdout.splitlines()) + self.assertEqual(set(value), {prefix + "_AUTH_BYPASS_ENABLED", prefix + "_AUTH_BYPASS_TOKEN"}) + self.assertEqual(value[prefix + "_AUTH_BYPASS_ENABLED"], "true") + self.assertTrue(len(value[prefix + "_AUTH_BYPASS_TOKEN"]) >= 32) + values.append(value) + self.assertTrue(values[0] != values[1], "Session hooks must mint a fresh token") + auth = session["auth_bypass"] + self.assertEqual(set(auth["launch_vars"]), set(values[0])) + link = urlparse(auth["deep_link"]) + self.assertEqual(link.scheme, scheme) + self.assertEqual(link.netloc, "revyl-auth") + self.assertEqual(parse_qs(link.query), {"token": ["${" + prefix + "_AUTH_BYPASS_TOKEN}"], "role": [role], "redirect": ["/home"]}) + + def test_review_policies_select_their_own_app(self): + for config in self.configs: + review = config["pr_review"] + self.assertTrue(review["enabled"]) + self.assertTrue(review["proof_of_changes"]["enabled"]) + self.assertEqual(review["proof_of_changes"]["harness"]["kind"], "revyl") + self.assertTrue(review["strict_ci_check"]["build"]) + self.assertTrue(review["review_triggers"]["paths"]) + self.assertNotIn("apps/rides/**", self.configs[0]["pr_review"]["review_triggers"]["paths"]) + self.assertIn("services/ride-demo/**", self.configs[1]["pr_review"]["review_triggers"]["paths"]) + self.assertNotIn("EatsClone/**", self.configs[1]["pr_review"]["review_triggers"]["paths"]) + + def test_rides_launcher_uses_nested_project_for_both_roles(self): + script = ROOT / "apps/rides/scripts/start-session.py" + for role in ["rider", "driver"]: + output = io.StringIO() + response = subprocess.CompletedProcess([], 0, stdout=json.dumps({"session_id": "test-session", "launch_env": "must-not-be-printed"}), stderr="") + with patch.object(sys, "argv", [str(script), "--role", role]), patch("subprocess.run", return_value=response) as run, contextlib.redirect_stdout(output): + runpy.run_path(str(script), run_name="__main__") + self.assertEqual(run.call_args.kwargs["cwd"], ROOT / "apps/rides") + command = run.call_args.args[0] + link = command[command.index("--app-link") + 1] + self.assertTrue(urlparse(link).scheme == "ridesdemo") + self.assertTrue(parse_qs(urlparse(link).query)["role"] == [role]) + app = next(iter(self.configs[1]["build"]["profiles"].values()))["ios"]["app_id"] + self.assertTrue(command[command.index("--app-id") + 1] == app) + self.assertEqual(json.loads(output.getvalue()), {"session_id": "test-session", "role": role}) + + def test_runtime_state_is_ignored_for_both_projects(self): + for root in self.projects: + path = root / ".revyl/before-session-values.json" + result = subprocess.run(["git", "check-ignore", "-q", str(path)], cwd=ROOT) + self.assertEqual(result.returncode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/ride-demo/.dockerignore b/services/ride-demo/.dockerignore new file mode 100644 index 0000000..750f971 --- /dev/null +++ b/services/ride-demo/.dockerignore @@ -0,0 +1,4 @@ +.data +node_modules +npm-debug.log +.git diff --git a/services/ride-demo/.gitignore b/services/ride-demo/.gitignore new file mode 100644 index 0000000..d071829 --- /dev/null +++ b/services/ride-demo/.gitignore @@ -0,0 +1 @@ +.data/ diff --git a/services/ride-demo/Dockerfile b/services/ride-demo/Dockerfile new file mode 100644 index 0000000..70703fe --- /dev/null +++ b/services/ride-demo/Dockerfile @@ -0,0 +1,7 @@ +FROM node:24-alpine +ENV NODE_ENV=production PORT=8787 HOST=0.0.0.0 DATA_DIR=/data +WORKDIR /app +COPY package.json server.js ./ +EXPOSE 8787 +VOLUME ["/data"] +CMD ["node", "server.js"] diff --git a/services/ride-demo/README.md b/services/ride-demo/README.md new file mode 100644 index 0000000..1affabb --- /dev/null +++ b/services/ride-demo/README.md @@ -0,0 +1,38 @@ +# Shared ride demo server + +Dependency-free Node.js 24 HTTP service for the independent ride demo. It is an Uber-inspired public demo, not production dispatch, payment, or authentication software. The room code is an invitation capability; anyone who knows it can join. Keep demo data non-sensitive. + +## Run + +```sh +node --version # Node 24 or newer +npm start +npm test +``` + +The service listens on `HOST` (default `0.0.0.0`) and `PORT` (default `8787`). SQLite is stored in `DATA_DIR/rides.sqlite` (`DATA_DIR` defaults to `.data`); WAL is enabled. Persist `/data` when running the Docker image. Rooms expire after 24 hours by default; expiry denies access but does not purge stored data. The demo rate limit is 240 requests per IP per minute, using the direct socket address and deliberately ignoring forwarded headers. + +```sh +docker build -t shared-ride-demo . +docker run --rm -p 8787:8787 -v ride-demo-data:/data shared-ride-demo +``` + +## HTTP and session contract + +JSON errors are `{ "error": "..." }`. `POST /v1/rooms` takes `{ "name": "..." }` and returns a `Session` with HTTP 201. `POST /v1/rooms/:code/join` takes `{ "role": "rider"|"driver", "name": "..." }` and returns a session. Joining is public by design: possession of the cryptographically random room code grants invitation access. A rider join recovers the room owner's rider identity; each driver receives a distinct identity. Sessions contain a 32-byte random bearer token; only its SHA-256 hash is stored. Send it only as `Authorization: Bearer `. Tokens in query strings are rejected. No endpoint accepts arbitrary state updates. + +Authenticated endpoints return the `State` object, except health and the two session-creation endpoints: + +| Method and path | Input | Authorization | +| --- | --- | --- | +| `GET /health` | — | Public; `{ "ok": true }` | +| `GET /v1/state` | — | Any room participant | +| `POST /v1/availability` | `{ "online": boolean }` | Driver | +| `POST /v1/trips` | `{ "requestId": UUID, "pickup": Place, "destination": Place, "stops": Place[], "service": string, "paymentMethod": string, "scheduledAt": string|null, "note": string }` | Room-owner rider | +| `POST /v1/trips/:id/actions` | See actions below | Depends on action | + +Trip actions are `{ "action": "accept" }`, `arrive`, `start`, `complete`, `{ "action": "cancel", "reason"?: string }`, `{ "action": "rate", "rating": 1..5, "tipCents": 0..10000 }`, and `{ "action": "message", "text": string }`. Only the room-owning rider or the assigned driver may cancel or chat. A driver can accept only while online; acceptance is atomic, so exactly one driver wins. A driver cannot go offline while assigned to an active trip. Chat is available in accepted, arriving, and in-progress trips; history is capped at 100 messages. + +`Place` is `{id,name,address,latitude,longitude}`. `Driver` is `{id,name,vehicle,plate,rating}` and the demo vehicle is Toyota Prius / DEMO 24 / 4.98. `Message` is `{id,senderId,senderName,text,createdAt}`. `Trip` is `{id,riderId,riderName,pickup,destination,stops,service,paymentMethod,scheduledAt,note,status,driver,fareCents,createdAt,updatedAt,cancellationReason,rating,tipCents,messages}`. `State` is `{roomCode,revision,driverOnline,onlineDriverIds,trips}` with newest first. `driverOnline` is aggregate availability; `onlineDriverIds` identifies available driver participants across restarts. Date fields are ISO 8601 strings; nullable fields are explicit. Statuses are `requested`, `accepted`, `arriving`, `in_progress`, `completed`, and `cancelled`. + +Services and base fares in cents: `uberX` 1840, `comfort` 2490, `uberXL` 2990, `green` 1940, `black` 3890, and `pet` 2140. Each stop adds 300 cents. Payment method is a bounded demo label; payment processing is not implemented, and long card-number-like strings are rejected. Scheduling accepts a future ISO date through 30 days; missing or null `scheduledAt` means immediate pickup, including clients that omit nil optionals. Drivers may accept and manually advance scheduled rides immediately; the server does not dispatch or wait for scheduled time. Route place IDs and coordinates must be unique. Revisions increment on committed state changes. Requests are limited to 32 KiB and invalid transitions do not mutate state. diff --git a/services/ride-demo/package.json b/services/ride-demo/package.json new file mode 100644 index 0000000..0dead87 --- /dev/null +++ b/services/ride-demo/package.json @@ -0,0 +1,11 @@ +{ + "name": "shared-ride-demo", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node server.js", + "test": "node --test" + }, + "engines": { "node": ">=24" } +} diff --git a/services/ride-demo/server.js b/services/ride-demo/server.js new file mode 100644 index 0000000..3f0c351 --- /dev/null +++ b/services/ride-demo/server.js @@ -0,0 +1,305 @@ +import { createServer as httpServer } from 'node:http'; +import { DatabaseSync } from 'node:sqlite'; +import { randomBytes, createHash } from 'node:crypto'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const services = { uberX: 1840, comfort: 2490, uberXL: 2990, green: 1940, black: 3890, pet: 2140 }; +const activeStatuses = ['requested', 'accepted', 'arriving', 'in_progress']; +const roomAlphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const isoNow = () => new Date().toISOString(); +const json = value => JSON.stringify(value); +const digest = token => createHash('sha256').update(token).digest('hex'); + +function fail(status, error) { + const result = new Error(error); + result.status = status; + throw result; +} + +function validString(value, max, field, { required = true } = {}) { + if (typeof value !== 'string' || (required && !value.trim()) || value.length > max) fail(400, `Invalid ${field}`); + return value.trim(); +} + +function validPlace(value, field) { + if (!value || typeof value !== 'object' || Array.isArray(value)) fail(400, `Invalid ${field}`); + return { + id: validString(value.id, 100, `${field}.id`), + name: validString(value.name, 200, `${field}.name`), + address: validString(value.address, 300, `${field}.address`), + latitude: coordinate(value.latitude, -90, 90, `${field}.latitude`), + longitude: coordinate(value.longitude, -180, 180, `${field}.longitude`) + }; +} + +function coordinate(value, min, max, field) { + if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) fail(400, `Invalid ${field}`); + return value; +} + +export function createRideServer({ dataDir = process.env.DATA_DIR ?? '.data', rateLimit = 240, rateWindowMs = 60_000, roomTtlMs = 24 * 60 * 60_000 } = {}) { + mkdirSync(dataDir, { recursive: true }); + const db = new DatabaseSync(join(dataDir, 'rides.sqlite')); + db.exec('PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;'); + db.exec(`CREATE TABLE IF NOT EXISTS rooms (code TEXT PRIMARY KEY, rider_id TEXT NOT NULL, rider_name TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS participants (id TEXT PRIMARY KEY, room_code TEXT NOT NULL REFERENCES rooms(code), token_hash TEXT NOT NULL UNIQUE, role TEXT NOT NULL, name TEXT NOT NULL, online INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS credentials (token_hash TEXT PRIMARY KEY, participant_id TEXT NOT NULL REFERENCES participants(id)); + CREATE TABLE IF NOT EXISTS trips (id TEXT PRIMARY KEY, room_code TEXT NOT NULL REFERENCES rooms(code), request_id TEXT NOT NULL, request_payload TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(room_code, request_id));`); + const rate = new Map(); + const stmts = { + room: db.prepare('SELECT * FROM rooms WHERE code = ?'), + participantByToken: db.prepare('SELECT p.* FROM credentials c JOIN participants p ON p.id = c.participant_id WHERE c.token_hash = ?'), + participant: db.prepare('SELECT * FROM participants WHERE id = ? AND room_code = ?'), + participants: db.prepare('SELECT * FROM participants WHERE room_code = ?'), + trips: db.prepare('SELECT payload FROM trips WHERE room_code = ? ORDER BY created_at DESC, id DESC'), + trip: db.prepare('SELECT * FROM trips WHERE id = ? AND room_code = ?'), + request: db.prepare('SELECT * FROM trips WHERE room_code = ? AND request_id = ?'), + insertRoom: db.prepare('INSERT INTO rooms(code, rider_id, rider_name, created_at) VALUES (?, ?, ?, ?)'), + insertParticipant: db.prepare('INSERT INTO participants(id, room_code, token_hash, role, name) VALUES (?, ?, ?, ?, ?)'), + insertTrip: db.prepare('INSERT INTO trips(id, room_code, request_id, request_payload, payload, created_at) VALUES (?, ?, ?, ?, ?, ?)'), + saveTrip: db.prepare('UPDATE trips SET payload = ? WHERE id = ?'), + setOnline: db.prepare('UPDATE participants SET online = ? WHERE id = ?'), + revision: db.prepare('UPDATE rooms SET revision = revision + 1 WHERE code = ?'), + }; + + function state(code) { + const room = stmts.room.get(code); + if (!room) fail(404, 'Room not found'); + const people = stmts.participants.all(code); + return { + roomCode: code, + revision: room.revision, + driverOnline: people.some(person => person.role === 'driver' && person.online === 1), + onlineDriverIds: people.filter(person => person.role === 'driver' && person.online === 1).map(person => person.id), + trips: stmts.trips.all(code).map(row => JSON.parse(row.payload)) + }; + } + + function transaction(action) { + db.exec('BEGIN IMMEDIATE'); + try { + const result = action(); + db.exec('COMMIT'); + return result; + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } + } + + function session(code, participant) { + const token = randomBytes(32).toString('base64url'); + const existing = stmts.participant.get(participant.id, code); + if (!existing) stmts.insertParticipant.run(participant.id, code, digest(token), participant.role, participant.name); + db.prepare('INSERT INTO credentials(token_hash, participant_id) VALUES (?, ?)').run(digest(token), participant.id); + if (participant.role === 'rider') { + db.prepare('UPDATE rooms SET rider_id = ?, rider_name = ? WHERE code = ?').run(participant.id, participant.name, code); + } + return { roomCode: code, participantId: participant.id, token, role: participant.role, name: participant.name, state: state(code) }; + } + + function makeCode() { + for (let attempt = 0; attempt < 20; attempt++) { + const code = [...randomBytes(8)].map(byte => roomAlphabet[byte % roomAlphabet.length]).join(''); + if (!stmts.room.get(code)) return code; + } + fail(500, 'Could not allocate room'); + } + + async function body(req) { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > 32_768) fail(400, 'Request body too large'); + chunks.push(chunk); + } + if (!size) return {}; + try { + const value = JSON.parse(Buffer.concat(chunks).toString('utf8')); + if (!value || typeof value !== 'object' || Array.isArray(value)) fail(400, 'Expected JSON object'); + return value; + } catch (error) { + if (error.status) throw error; + fail(400, 'Invalid JSON'); + } + } + + async function handle(req, res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + if (req.method === 'OPTIONS') { res.writeHead(204).end(); return; } + const window = Math.floor(Date.now() / rateWindowMs); + const key = `${req.socket.remoteAddress}:${window}`; + const count = (rate.get(key) ?? 0) + 1; + rate.set(key, count); + if (count > rateLimit) fail(429, 'Rate limit exceeded'); + for (const entry of rate.keys()) if (!entry.endsWith(`:${window}`)) rate.delete(entry); + + const url = new URL(req.url, 'http://localhost'); + if (url.searchParams.has('token')) fail(400, 'Tokens are not accepted in query strings'); + if (req.method === 'GET' && url.pathname === '/health') { respond(res, 200, { ok: true }); return; } + if (req.method === 'POST' && url.pathname === '/v1/rooms') { + const input = await body(req); + const name = validString(input.name, 80, 'name'); + const roomCode = makeCode(); + const riderId = randomBytes(16).toString('hex'); + const result = transaction(() => { + stmts.insertRoom.run(roomCode, riderId, name, isoNow()); + return session(roomCode, { id: riderId, role: 'rider', name }); + }); + respond(res, 201, result); return; + } + const joinMatch = url.pathname.match(/^\/v1\/rooms\/([A-Z0-9]+)\/join$/); + if (req.method === 'POST' && joinMatch) { + const code = joinMatch[1]; + if (code.length < 8 || ![...code].every(char => roomAlphabet.includes(char))) fail(404, 'Room not found'); + const room = stmts.room.get(code); + if (!room || Date.now() - Date.parse(room.created_at) > roomTtlMs) fail(404, 'Room expired'); + const input = await body(req); + const role = input.role; + const name = validString(input.name, 80, 'name'); + if (role !== 'rider' && role !== 'driver') fail(400, 'Invalid role'); + const result = transaction(() => { + const id = role === 'rider' ? room.rider_id : randomBytes(16).toString('hex'); + const sessionName = role === 'rider' ? stmts.participant.get(id, code).name : name; + return session(code, { id, role, name: sessionName }); + }); + respond(res, 201, result); return; + } + if (!['/v1/state', '/v1/availability', '/v1/trips'].includes(url.pathname) && !/^\/v1\/trips\/[A-Za-z0-9_-]+\/actions$/.test(url.pathname)) fail(404, 'Not found'); + const auth = req.headers.authorization; + if (typeof auth !== 'string' || !auth.startsWith('Bearer ') || auth.length < 16) fail(401, 'Bearer token required'); + const participant = stmts.participantByToken.get(digest(auth.slice(7))); + if (!participant) fail(401, 'Invalid bearer token'); + const code = participant.room_code; + const room = stmts.room.get(code); + if (!room || Date.now() - Date.parse(room.created_at) > roomTtlMs) fail(404, 'Room expired'); + if (req.method === 'GET' && url.pathname === '/v1/state') { respond(res, 200, state(code)); return; } + if (req.method === 'POST' && url.pathname === '/v1/availability') { + const input = await body(req); + if (participant.role !== 'driver') fail(403, 'Driver role required'); + if (typeof input.online !== 'boolean') fail(400, 'online must be boolean'); + transaction(() => { + if (!input.online && stmts.trips.all(code).some(row => { + const trip = JSON.parse(row.payload); + return trip.driver?.id === participant.id && activeStatuses.includes(trip.status); + })) fail(409, 'Cannot go offline during an active trip'); + stmts.setOnline.run(input.online ? 1 : 0, participant.id); + stmts.revision.run(code); + }); + respond(res, 200, state(code)); return; + } + if (req.method === 'POST' && url.pathname === '/v1/trips') { + const input = await body(req); + if (participant.role !== 'rider' || participant.id !== room.rider_id) fail(403, 'Room owner rider required'); + const requestId = validString(input.requestId, 36, 'requestId'); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(requestId)) fail(400, 'requestId must be a UUID'); + const pickup = validPlace(input.pickup, 'pickup'); + const destination = validPlace(input.destination, 'destination'); + if (!Array.isArray(input.stops) || input.stops.length > 2) fail(400, 'stops must contain at most 2 places'); + const stops = input.stops.map((place, index) => validPlace(place, `stops[${index}]`)); + const routePoints = [pickup, ...stops, destination]; + if (new Set(routePoints.map(point => point.id)).size !== routePoints.length) fail(400, 'Route place IDs must be unique'); + if (new Set(routePoints.map(point => `${point.latitude},${point.longitude}`)).size !== routePoints.length) fail(400, 'Route coordinates must be unique'); + if (!Object.hasOwn(services, input.service)) fail(400, 'Unsupported service'); + const paymentMethod = validString(input.paymentMethod, 80, 'paymentMethod'); + if (/[0-9]{12,}/.test(paymentMethod)) fail(400, 'Payment details are not accepted'); + let scheduledAt = null; + if (input.scheduledAt !== undefined && input.scheduledAt !== null) { + if (typeof input.scheduledAt !== 'string' || !/^\d{4}-\d\d-\d\dT/.test(input.scheduledAt) || !Number.isFinite(Date.parse(input.scheduledAt))) fail(400, 'Invalid scheduledAt'); + scheduledAt = new Date(input.scheduledAt).toISOString(); + const delta = Date.parse(scheduledAt) - Date.now(); + if (delta <= 0 || delta > 30 * 24 * 60 * 60_000) fail(400, 'scheduledAt must be within the next 30 days'); + } + const note = input.note === undefined ? '' : validString(input.note, 500, 'note', { required: false }); + const payload = { pickup, destination, stops, service: input.service, paymentMethod, scheduledAt, note }; + const requestPayload = json(payload); + const result = transaction(() => { + const previous = stmts.request.get(code, requestId); + if (previous) { + if (previous.request_payload !== requestPayload) fail(409, 'requestId already used with different payload'); + return JSON.parse(previous.payload); + } + if (stmts.trips.all(code).some(row => activeStatuses.includes(JSON.parse(row.payload).status))) fail(409, 'Room already has an active trip'); + const createdAt = isoNow(); + const trip = { id: randomBytes(16).toString('hex'), riderId: participant.id, riderName: participant.name, ...payload, status: 'requested', driver: null, fareCents: services[input.service] + 300 * stops.length, createdAt, updatedAt: createdAt, cancellationReason: null, rating: null, tipCents: 0, messages: [] }; + stmts.insertTrip.run(trip.id, code, requestId, requestPayload, json(trip), createdAt); + stmts.revision.run(code); + return trip; + }); + void result; + respond(res, 201, state(code)); return; + } + const actionMatch = url.pathname.match(/^\/v1\/trips\/([A-Za-z0-9_-]+)\/actions$/); + if (req.method === 'POST' && actionMatch) { + const tripId = actionMatch[1]; + const input = await body(req); + const action = validString(input.action, 20, 'action'); + const updated = transaction(() => { + const row = stmts.trip.get(tripId, code); + if (!row) fail(404, 'Trip not found'); + const trip = JSON.parse(row.payload); + const owner = participant.role === 'rider' && participant.id === trip.riderId; + const assigned = participant.role === 'driver' && participant.id === trip.driver?.id; + if (action === 'accept') { + if (participant.role !== 'driver') fail(403, 'Driver role required'); + if (!stmts.participant.get(participant.id, code)?.online) fail(409, 'Driver must be online'); + if (trip.status !== 'requested') fail(409, 'Trip is no longer available'); + trip.status = 'accepted'; + trip.driver = { id: participant.id, name: participant.name, vehicle: 'Toyota Prius', plate: 'DEMO 24', rating: 4.98 }; + } else if (action === 'arrive' || action === 'start' || action === 'complete') { + if (!assigned) fail(403, 'Assigned driver required'); + const transition = { arrive: ['accepted', 'arriving'], start: ['arriving', 'in_progress'], complete: ['in_progress', 'completed'] }[action]; + if (trip.status !== transition[0]) fail(409, `Cannot ${action} trip from ${trip.status}`); + trip.status = transition[1]; + } else if (action === 'cancel') { + if (!owner && !assigned) fail(403, 'Trip owner or assigned driver required'); + if (!['requested', 'accepted', 'arriving'].includes(trip.status)) fail(409, `Cannot cancel trip from ${trip.status}`); + trip.status = 'cancelled'; + trip.cancellationReason = input.reason === undefined ? null : validString(input.reason, 300, 'reason', { required: false }); + } else if (action === 'rate') { + if (!owner) fail(403, 'Trip owner rider required'); + if (trip.status !== 'completed') fail(409, 'Only completed trips can be rated'); + if (!Number.isInteger(input.rating) || input.rating < 1 || input.rating > 5) fail(400, 'rating must be an integer from 1 to 5'); + if (!Number.isInteger(input.tipCents) || input.tipCents < 0 || input.tipCents > 10_000) fail(400, 'tipCents must be an integer from 0 to 10000'); + trip.rating = input.rating; + trip.tipCents = input.tipCents; + } else if (action === 'message') { + if (!owner && !assigned) fail(403, 'Trip owner or assigned driver required'); + if (!['accepted', 'arriving', 'in_progress'].includes(trip.status)) fail(409, 'Chat is unavailable for this trip'); + if (trip.messages.length >= 100) fail(409, 'Message history is full'); + const text = validString(input.text, 500, 'text'); + trip.messages.push({ id: randomBytes(16).toString('hex'), senderId: participant.id, senderName: participant.name, text, createdAt: isoNow() }); + } else fail(400, 'Unsupported action'); + trip.updatedAt = isoNow(); + stmts.saveTrip.run(json(trip), trip.id); + stmts.revision.run(code); + return trip; + }); + respond(res, 200, state(code)); return; + } + fail(404, 'Not found'); + } + + function respond(res, status, value) { + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(json(value)); + } + + const server = httpServer((req, res) => { + handle(req, res).catch(error => { + if (!res.headersSent) respond(res, error.status ?? 500, { error: error.status ? error.message : 'Internal server error' }); + else res.destroy(); + }); + }); + server.on('close', () => db.close()); + return server; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const server = createRideServer(); + server.listen(Number(process.env.PORT ?? 8787), process.env.HOST ?? '0.0.0.0'); +} diff --git a/services/ride-demo/server.test.js b/services/ride-demo/server.test.js new file mode 100644 index 0000000..7ddb0b8 --- /dev/null +++ b/services/ride-demo/server.test.js @@ -0,0 +1,137 @@ +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createRideServer } from './server.js'; + +let dir; +let server; +let base; + +before(async () => { + dir = mkdtempSync(join(tmpdir(), 'ride-demo-')); + server = createRideServer({ dataDir: dir }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + base = `http://127.0.0.1:${server.address().port}`; +}); + +after(async () => { + await new Promise(resolve => server.close(resolve)); + rmSync(dir, { recursive: true, force: true }); +}); + +async function call(path, { token, method = 'GET', body } = {}) { + const response = await fetch(`${base}${path}`, { + method, + headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}) }, + body: body ? JSON.stringify(body) : undefined + }); + return { status: response.status, data: await response.json() }; +} + +async function setup(name = 'Rider') { + const created = await call('/v1/rooms', { method: 'POST', body: { name } }); + const roomCode = created.data.roomCode; + const join = async (role, name) => call(`/v1/rooms/${roomCode}/join`, { method: 'POST', body: { role, name } }); + return { rider: created.data, roomCode, join }; +} + +const place = (id, name, latitude, longitude) => ({ id, name, address: `${name} address`, latitude, longitude }); +const booking = (requestId = '11111111-1111-4111-8111-111111111111') => ({ + requestId, pickup: place('p', 'Pickup', 37.7, -122.4), destination: place('d', 'Dropoff', 37.8, -122.5), + stops: [], service: 'uberX', paymentMethod: 'Demo Visa •••• 4242', scheduledAt: null, note: '' +}); + +test('real clients progress through booking, assignment, chat, completion, and rating', async () => { + const { rider, roomCode, join } = await setup(); + const driver = (await join('driver', 'Driver')).data; + assert.equal(rider.token.length > 40, true); + const created = await call('/v1/trips', { method: 'POST', token: rider.token, body: booking() }); + assert.equal(created.status, 201); + const tripId = created.data.trips[0].id; + assert.equal(created.data.trips[0].fareCents, 1840); + assert.equal(created.data.trips[0].scheduledAt, null); + assert.equal((await call('/v1/state', { token: driver.token })).data.trips[0].status, 'requested'); + assert.equal((await call('/v1/availability', { method: 'POST', token: driver.token, body: { online: true } })).data.driverOnline, true); + const accepted = await call(`/v1/trips/${tripId}/actions`, { method: 'POST', token: driver.token, body: { action: 'accept' } }); + assert.equal(accepted.data.trips[0].driver.vehicle, 'Toyota Prius'); + for (const action of ['arrive', 'start', 'complete']) assert.equal((await call(`/v1/trips/${tripId}/actions`, { method: 'POST', token: driver.token, body: { action } })).status, 200); + const rated = await call(`/v1/trips/${tripId}/actions`, { method: 'POST', token: rider.token, body: { action: 'rate', rating: 5, tipCents: 500 } }); + assert.equal(rated.data.trips[0].rating, 5); + assert.equal(rated.data.trips[0].tipCents, 500); + assert.equal(rated.data.roomCode, roomCode); +}); + +test('room identity recovery, scoped idempotency, and separate room visibility', async () => { + const { rider, roomCode } = await setup('Owner'); + const recovered = (await call(`/v1/rooms/${roomCode}/join`, { method: 'POST', body: { role: 'rider', name: 'Recovered' } })).data; + assert.equal(recovered.participantId, rider.participantId); + assert.equal(recovered.name, rider.name); + const first = await call('/v1/trips', { method: 'POST', token: rider.token, body: booking() }); + assert.equal(first.data.trips[0].riderName, recovered.name); + const repeated = await call('/v1/trips', { method: 'POST', token: rider.token, body: booking() }); + assert.equal(first.data.trips[0].id, repeated.data.trips[0].id); + assert.equal((await call('/v1/trips', { method: 'POST', token: rider.token, body: booking('11111111-1111-4111-8111-111111111112') })).status, 409); + const other = await setup('Other'); + assert.equal((await call('/v1/state', { token: other.rider.token })).data.trips.length, 0); + assert.notEqual(other.roomCode, roomCode); +}); + +test('concurrent drivers cannot both accept, unauthorized actions and cancel/chat rules hold', async () => { + const { rider, roomCode, join } = await setup(); + const d1 = (await join('driver', 'One')).data; + const d2 = (await join('driver', 'Two')).data; + const id = (await call('/v1/trips', { method: 'POST', token: rider.token, body: booking() })).data.trips[0].id; + await Promise.all([d1, d2].map(driver => call('/v1/availability', { method: 'POST', token: driver.token, body: { online: true } }))); + const results = await Promise.all([d1, d2].map(driver => call(`/v1/trips/${id}/actions`, { method: 'POST', token: driver.token, body: { action: 'accept' } }))); + assert.deepEqual(results.map(result => result.status).sort(), [200, 409]); + const assigned = results.find(result => result.status === 200).data.trips[0].driver.id; + const driver = [d1, d2].find(value => value.participantId === assigned); + const otherDriver = [d1, d2].find(value => value.participantId !== assigned); + assert.equal((await call(`/v1/trips/${id}/actions`, { method: 'POST', token: rider.token, body: { action: 'message', text: 'On my way' } })).data.trips[0].messages.length, 1); + assert.equal((await call(`/v1/trips/${id}/actions`, { method: 'POST', token: driver.token, body: { action: 'cancel', reason: 'Changed plans' } })).data.trips[0].status, 'cancelled'); + assert.equal((await call(`/v1/trips/${id}/actions`, { method: 'POST', token: otherDriver.token, body: { action: 'message', text: 'No access' } })).status, 403); + assert.equal((await call('/v1/state', { token: driver.token })).data.roomCode, roomCode); +}); + +test('validates auth, role, malformed inputs, query credentials, and transition without mutation', async () => { + const { rider, roomCode, join } = await setup(); + assert.equal((await call('/v1/state')).status, 401); + assert.equal((await call('/v1/state?token=secret', { token: rider.token })).status, 400); + assert.equal((await call('/v1/state', { token: 'invalid' })).status, 401); + assert.equal((await call('/v1/availability', { method: 'POST', token: rider.token, body: { online: true } })).status, 403); + assert.equal((await call('/v1/trips', { method: 'POST', token: rider.token, body: { ...booking(), service: 'unsupported' } })).status, 400); + const id = (await call('/v1/trips', { method: 'POST', token: rider.token, body: booking() })).data.trips[0].id; + const before = (await call('/v1/state', { token: rider.token })).data.revision; + assert.equal((await call(`/v1/trips/${id}/actions`, { method: 'POST', token: rider.token, body: { action: 'complete' } })).status, 403); + assert.equal((await call('/v1/state', { token: rider.token })).data.revision, before); + assert.equal((await join('bogus', 'Nope')).status, 400); +}); + +test('omitted schedule defaults to immediate and duplicate route points are rejected', async () => { + const { rider } = await setup(); + const noSchedule = booking(); + delete noSchedule.scheduledAt; + const accepted = await call('/v1/trips', { method: 'POST', token: rider.token, body: noSchedule }); + assert.equal(accepted.status, 201); + assert.equal(accepted.data.trips[0].scheduledAt, null); + const duplicateId = booking('22222222-2222-4222-8222-222222222222'); + duplicateId.destination = { ...duplicateId.destination, id: duplicateId.pickup.id }; + assert.equal((await call('/v1/trips', { method: 'POST', token: rider.token, body: duplicateId })).status, 400); + const duplicateCoordinates = booking('33333333-3333-4333-8333-333333333333'); + duplicateCoordinates.destination = { ...duplicateCoordinates.destination, latitude: duplicateCoordinates.pickup.latitude, longitude: duplicateCoordinates.pickup.longitude }; + assert.equal((await call('/v1/trips', { method: 'POST', token: rider.token, body: duplicateCoordinates })).status, 400); +}); + +test('SQLite WAL persists sessions and state after server restart', async () => { + const { rider } = await setup('Persistent'); + await call('/v1/trips', { method: 'POST', token: rider.token, body: booking() }); + await new Promise(resolve => server.close(resolve)); + server = createRideServer({ dataDir: dir }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + base = `http://127.0.0.1:${server.address().port}`; + const restored = await call('/v1/state', { token: rider.token }); + assert.equal(restored.status, 200); + assert.equal(restored.data.trips[0].riderName, 'Persistent'); +});