Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

QueueUp SDK (Android & iOS)

QueueUp ships two pieces:

  • Data SDK — a Kotlin Multiplatform API client for the QueueUp platform (merchants, products, checkouts, payments, fulfillment, wallet, vouchers, memberships, and the end-user auth flow). Use it on its own when you build your own UI.
  • UI SDK — prebuilt, themeable Compose Multiplatform UI you can drop straight into your app.

The UI SDK is built on the Data SDK. It includes and re-exposes the Data SDK, so adding the UI SDK gives you the prebuilt screens and the full data API from a single dependency — you never add both. Building your own UI instead? Take the Data SDK on its own.

Which module do I need?

You want to… Take Setup
Call the QueueUp API and build your own UI Data SDK QueueUp.init(…) → a QueueUpClient; every API call hangs off it.
Use the SDK's prebuilt UI UI SDK Includes the Data SDK, so the data endpoints are available too. Configure an optional theme, then render.

Install

Packaging differs by platform: Android = two artifacts (add just one — the UI artifact already includes the data one); iOS = one package (contains both).

Android — two artifacts

Declare PINCH's Maven repository (e.g. in settings.gradle(.kts)):

repositories {
    google()
    mavenCentral()
    maven { url = uri("https://maven.pinch.nl/maven") }
}

Then add the module(s) you need (e.g. in app/build.gradle(.kts)):

dependencies {
    // Data SDK only — you build your own UI:
    implementation("eu.queueup:queueup:{x.y.z}")

    // …or the UI SDK (includes the Data SDK)
    implementation("eu.queueup:queueup-ui:{x.y.z}")
}

iOS — one package

iOS can't link two independent Kotlin frameworks into one app, so both SDKs are delivered in a single Swift package. Add it once (Xcode → File → Add Package Dependency):

https://github.com/pinchbv/lib-queueup

import QueueUp exposes both the Data and UI APIs — there is nothing extra to add for the UI SDK on iOS.


Data SDK

Skip this section if you only render the UI SDK's prebuilt screens and don't call the API yourself. The UI SDK includes the Data SDK, so everything below is also available to UI SDK consumers who want to hit the endpoints directly.

The Data SDK lets your app talk to the QueueUp platform. Android consumes it as a Kotlin library; iOS as an Objective-C / Swift framework (the QueueUp package above).

Initialization

Initialize the Data SDK once per process, as early as possible (e.g. in your Application.onCreate on Android or your App initializer on iOS). init returns a QueueUpClient; keep this handle — all domain APIs hang off it.

init may throw QueueUpInitException if startup fails.

Android

val queueUp = QueueUp.init(
    environment = Environment.Production,
    campaignId = "your-campaign-id",
    logger = { message -> Log.d("QueueUp", message) }, // optional
)

iOS

MyApp.queueUp = QueueUp.shared.doInit(
    environment: .production,
    campaignId: "your-campaign-id",
    logger: PrintLogger() // optional, conforms to QueueUp.Logger
)

Calling init again tears down the previous client and replaces it; discard any handle returned by an earlier call.

Environments

Value Use for
Environment.Acceptance Integration / staging against QueueUp's acceptance backend.
Environment.Production Live traffic.

Authentication

The SDK manages tokens (storage, refresh, attach to requests) for you — your job is to (a) drive the sign-in flow and (b) react when the SDK signals that authentication is needed.

Reacting to "authentication required"

client.auth.authenticationRequired is a SharedFlow<AuthenticationRequired?> (Swift sees it as an AsyncSequence). It emits:

  • null — no action needed (emitted after a successful sign-in or logout).
  • NoSession — no session has ever been established; prompt the user to sign in.
  • SessionExpired — a previously valid session could not be refreshed; prompt the user to sign in again.

It replays its latest value, so late subscribers immediately receive it — you won't miss the signal by subscribing after the SDK has already determined that authentication is required. It also re-emits even when the reason is unchanged, so every request that still needs authentication re-signals — e.g. if the user dismisses your sign-in prompt without signing in and then retries, you'll be prompted again.

Android:

viewModelScope.launch {
    client.auth.authenticationRequired
        .filterNotNull()
        .collect { reason ->
            when (reason) {
                AuthenticationRequired.NoSession -> showSignIn()
                AuthenticationRequired.SessionExpired -> showSignInExpired()
            }
        }
}

iOS:

Task { @MainActor in
    for await reason in client.auth.authenticationRequired {
        guard let reason else { continue }
        switch onEnum(of: reason) {
        case .noSession:     showSignIn()
        case .sessionExpired: showSignInExpired()
        }
    }
}

Sign-in flows

The SDK supports two sign-in flows; pick whichever fits your product.

Magic link (the default). The host requests a magic-link email, the user clicks the link, your app receives the callback URL via deep / universal link, and you hand it back to the SDK:

client.auth.requestMagicLink(
    email = "user@example.com",
    callbackUrl = "https://your.app/auth/callback", // must be whitelisted
)
// later, when your deep-link handler receives the callback URL:
client.auth.verifyMagicLink(url = receivedUrl)

Authorization code exchange. If the host already has a single-use server-to-server token, exchange it for a session directly:

client.auth.exchangeAuthorizationCode(code = "single-use-code")

Checking & ending sessions

// local check: do we have stored tokens?
client.auth.hasAuthenticated()

// local check: are the stored tokens still valid?
client.auth.isAuthenticated()

// server round-trip
client.auth.isAuthenticated(checkRemotely = true)

// sends a logout request and clears the local tokens
client.auth.logout()

Domain APIs

All domain APIs are reached from the QueueUpClient returned by init. Every method is suspend (Swift sees them as async throws). Errors are typed — see Error handling.

Property What it does
client.agreements Pending consent prompts (partner-driven).
client.checkouts Create and confirm checkouts.
client.fulfillment Available timeslots, the user's orders, their tickets, and the consolidated PDF.
client.memberships The user's memberships within the campaign.
client.merchants List, search, look up merchants in your campaign.
client.payments Look up a payment by ID.
client.products List products by merchant, look one up, fetch a calendar of availability.
client.vouchers Validate and redeem voucher codes.
client.wallet Loyalty token balance, transactions, partner reconciliation.

Agreements

val pending = client.agreements.getPendingAgreements(partnerUserToken = "...")
client.agreements.acceptPendingAgreements(
    partnerUserToken = "...",
    agreementsIds = pending.map { it.id }.toSet(),
)

Checkouts

createCheckout reserves capacity (a "checkout") that expires if not confirmed. confirmCheckout returns a PSP-hosted payment URL — redirect the user there. Both calls are idempotent — see Idempotency.

val checkout = client.checkouts.createCheckout(
    date = Clock.System.now(),
    flexibleDate = false,
    lines = listOf(Line(productId = "...", quantity = 2)),
    externalReference = "cart-123",
)

val confirmed = client.checkouts.confirmCheckout(
    checkoutId = checkout.id,
    email = "user@example.com",
    firstName = "First",
    lastName = "Last",
    postalCode = "1011AA",
    returnUrl = "https://your.app/payment/return",
)

Fulfillment (tickets)

val slots = client.fulfillment.getAvailableTimeslots(
    productIds = setOf("product-uuid"),
    date = LocalDate(2026, 7, 10),
)

val orders = client.fulfillment.getFulfillmentOrders(
    limit = 20,
    status = FulfillmentStatus.COMPLETED, // optional filter
)

// Single order (includes tickets + presigned PDF URL when ready):
val order = client.fulfillment.getFulfillmentOrderByOrderId(orderId)

Memberships

val memberships = client.memberships.getMemberships()

Merchants

val page = client.merchants.getAll(limit = 50)
val merchant = client.merchants.getById("merchant-uuid")
val nearby = client.merchants.search(
    query = "museum",
    geo = Geo(latitude = 52.37, longitude = 4.89, distanceMeters = 5_000),
)
// page.pagination.nextToken — pass to the next getAll/search to fetch the next page.

Payments

val payment = client.payments.getById("payment-id")

Products

val products = client.products.getAllProductsByMerchantId("merchant-uuid")
val product = client.products.getById(merchantId = "...", productId = "...")
val calendar = client.products.getProductCalendar(
    merchantId = "...",
    productId = "...",
    from = LocalDate(2026, 7, 1),
    to = LocalDate(2026, 7, 14),
)

Vouchers

val status = client.vouchers.validateVoucherCode("ABC123")
val redeemed = client.vouchers.redeemVoucher(code = "ABC123")

Wallet

val balance = client.wallet.getLoyaltyTokenBalance()
val txs = client.wallet.getTransactions(limit = 20)

// For partner-counter campaigns:
val reconciled = client.wallet.syncLoyaltyBalanceWithPartner(partnerUserToken = "...")

Idempotency

Some endpoints send an Idempotency-Key header so the backend collapses duplicate requests instead of acting on them twice (e.g., creating duplicate reservations or payment redirects). By default the SDK manages the key for you — you don't need to do anything.

Idempotency currently applies to client.checkouts.createCheckout and client.checkouts.confirmCheckout. More endpoints may opt in over time.

Default: SDK-managed. Call an idempotent endpoint without an idempotencyConfig and the SDK takes care of the Idempotency-Key for you, including reusing the same key for an immediate duplicate call (e.g., a double-tapped button) so the backend collapses it.

Override: caller-managed. Idempotent endpoints accept an idempotencyConfig: IdempotencyConfig? argument. Supplying one hands key management to you:

// Supply your own key — forwarded verbatim; the SDK will not generate or manage one for this call.
IdempotencyConfig(key = "my-idempotency-key")

When you provide a key, dedup, rotation, and any retry semantics become your responsibility on that call.

Error handling

All domain methods are declared @Throws(Throwable::class). The SDK throws a sealed eu.queueup.data.errors.Error for known failure shapes:

Type When
Error.Unauthorized 401 from the API. Tokens may have expired or are missing.
Error.ApiProblem Non-2xx with an RFC 7807 problem+json body. Inspect .problem.
Error.ApiError Non-2xx without a recognized problem body (e.g. proxied third-party errors).
Error.NetworkError IO / connectivity failure.

Init failures throw QueueUpInitException instead.

try {
    client.checkouts.createCheckout(/**/)
} catch (e: Error.ApiProblem) {
    // e.problem.title / e.problem.detail
} catch (e: Error.Unauthorized) {
    // SDK has already emitted on authenticationRequired
} catch (e: Error.NetworkError) {
    // retryable
}

UI SDK

Built on the Data SDK. The UI SDK includes and re-exposes the Data SDK, so the data endpoints (the QueueUpClient from the Data SDK above) are available from the UI artifact too. Rendering a screen needs only an optional theme; call QueueUp.init only when you want to interact with the QueueUp network API.

On Android the UI SDK is the eu.queueup:queueup-ui artifact, which depends on the Data SDK (eu.queueup:queueup); on iOS both live in the single QueueUp package (import QueueUp). It bundles Compose Multiplatform — Android renders Jetpack Compose, iOS renders the same Compose UI hosted in a UIViewController.

Theming

Configure the theme (colors, typography, component styles) once at startup, before showing any SDK UI. It applies to all UI the SDK produces. Until configured, a neutral default theme is used; call updateThemes again to re-theme live.

Supply an optional light and dark theme — the SDK switches between them automatically as the system theme changes. Omit dark to use light in both modes.

QueueUpUi.updateThemes(
    light = QueueUpTheme(
        colors = QueueUpColors(/* backgroundPrimary, labelPrimary, tintPrimaryMain, … */),
        typography = QueueUpTypography(/* heading1…heading4, body, caption */),
        styles = QueueUpStyles(
            button = QueueUpButtonStyle(/* shape, verticalPadding, horizontalPadding */),
            card = QueueUpCardStyle(/* shape */),
            input = QueueUpInputStyle(/* shape */),
        ),
    ),
    dark = QueueUpTheme(/**/), // optional
)

The same updateThemes API is available on iOS via the framework, with the same named parameters.

Loyalty flow

QueueUpUi.Loyalty is the single embed point for the whole prebuilt UI — there's nothing else to mount separately. It internally manages its own navigation between:

  • Onboarding — a self-contained pager of exactly three cards, shown once (persisted locally) the first time a user reaches the flow; every later launch goes straight to the landing screen.
  • The landing screen — balance, QR-scan-to-redeem, an outings link-out, "My tickets" (once authenticated), and links to transaction history and the general terms and conditions.
  • Everything reached from the landing screen — the QR scanner, ticket detail, transaction history, and the terms-acceptance sheet.

The terms-and-conditions link is a good example of how self-contained this is: it only appears once the SDK confirms (via client.agreements.getPendingAgreements, see Agreements) that something is actually pending, and hides again immediately once the user accepts — no host-side wiring needed.

Android

QueueUpUi.Loyalty(
    config = myLoyaltyConfig,
    partnerUserToken = { currentPartnerUserToken() }, // re-read on every request; may return null
    onOutingsClicked = { /* host app navigates to its own outings screen */ },
)

iOS

LoyaltyViewController(config:partnerUserToken:didRequestShowOutings:) returns a UIViewController hosting the Compose UI; wrap it in a UIViewControllerRepresentable (or present it directly).

struct LoyaltyScreen: UIViewControllerRepresentable {
    let onOutings: () -> Void

    func makeUIViewController(context: Context) -> UIViewController {
        LoyaltyViewController(
            config: myLoyaltyConfig,
            partnerUserToken: { currentPartnerUserToken() },
            didRequestShowOutings: onOutings
        )
    }

    func updateUIViewController(_ vc: UIViewController, context: Context) {}
}

Configuring LoyaltyConfig

One LoyaltyConfig covers the whole flow — re-supply it if the locale changes.

Field Covers
locale Locale used for localized content, e.g. "nl-NL".
general Shared error-toast/retry copy reused across screens.
landing The landing screen itself — header, scoreboard, outings, links.
onboarding The one-time onboarding pager — exactly 3 cards + a call-to-action.
qr Text shown in the QR scanner. Every field has a Dutch default.
tickets The "My tickets" screen, including empty/error states.
transactions The transaction history screen, including empty/error states.
terms The general-terms-and-conditions acceptance sheet.

Each field is its own config type with full KDoc on every property; the shape (trimmed here for readability):

val config = LoyaltyConfig(
    locale = "nl-NL",
    general = GeneralConfig(/* errorToastTitle, errorToastMessage, errorStateRetryCta, … */),
    landing = LandingConfig(/* title, scoreboardTitleNoBalance, outingsCta, linkMyTransactions, … */),
    onboarding = OnboardingConfig(
        cards = listOf(
            OnboardingCard(
                title = "Welcome",
                description = "Order ahead and skip the line.",
                image = ImageSource.remote("https://example.com/card1.png"),
            ),
            // … exactly 3 cards
        ),
        cta = OnboardingCta(label = "Get started"),
    ),
    qr = QrConfig(), // every field defaults — override only what you need
    tickets = MyTicketsConfig(/* emptyStateMessage, errorStateMessage, ticketCountTemplate, … */),
    transactions = TransactionsConfig(/* title, tableColumnDate, emptyStateMessage, … */),
    terms = TermsAndConditionsConfig(title = "Algemene voorwaarden", acceptCta = "Akkoord"),
)

Build images with the ImageSource factories: ImageSource.remote(url) (Android + iOS) or ImageSource.local(painter) (Android). On iOS, build a local image from a UIImage with ImageSource.companion.fromUIImage(image:).

About

Official QueueUp client SDK for Android and iOS.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages