diff --git a/app/src/androidMain/kotlin/com/crossmint/kotlin/wallet/playground/PasskeyCreation.android.kt b/app/src/androidMain/kotlin/com/crossmint/kotlin/wallet/playground/PasskeyCreation.android.kt index ce085f8..6bce9ab 100644 --- a/app/src/androidMain/kotlin/com/crossmint/kotlin/wallet/playground/PasskeyCreation.android.kt +++ b/app/src/androidMain/kotlin/com/crossmint/kotlin/wallet/playground/PasskeyCreation.android.kt @@ -1,24 +1,153 @@ package com.crossmint.kotlin.wallet.playground +import android.app.Activity import android.content.Context +import android.content.ContextWrapper import android.util.Base64 +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.CreatePublicKeyCredentialResponse import androidx.credentials.CredentialManager import androidx.credentials.exceptions.CreateCredentialCancellationException import com.crossmint.kotlin.signers.DelegatedSigner +import com.crossmint.kotlin.utility.exposeTestTags import java.math.BigInteger import java.security.SecureRandom +import kotlinx.coroutines.CompletableDeferred import org.json.JSONObject @Composable actual fun rememberPasskeyCreator(): (suspend (name: String) -> DelegatedSigner.Passkey?)? { val context = LocalContext.current + + // Mock mode is for CI e2e flows ONLY: GitHub Actions emulators cannot create real + // passkeys via CredentialManager (no signed-in Google account). The Maestro passkey + // flow launches the app with `arguments: { mockPasskey: true }`, which Android + // delivers as launcher-intent extras. Local/manual testing keeps the real path. + val mockMode = + remember(context) { + context + .findActivity() + ?.intent + ?.extras + ?.get("mockPasskey") + ?.toString() == "true" + } + + if (mockMode) { + return rememberMockPasskeyCreator() + } return { name -> createPasskeySigner(context, name) } } +private tailrec fun Context.findActivity(): Activity? = + when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } + +// --------------------------------------------------------------------------- +// Mock passkey creation (CI e2e only) — mirrors the Flutter playground dialog. +// --------------------------------------------------------------------------- + +@Composable +private fun rememberMockPasskeyCreator(): suspend (name: String) -> DelegatedSigner.Passkey? { + var pendingRequest by remember { mutableStateOf(null) } + + pendingRequest?.let { request -> + AlertDialog( + onDismissRequest = { + request.result.complete(null) + pendingRequest = null + }, + // AlertDialog hosts its own window: re-expose testTags for Maestro. + modifier = Modifier.exposeTestTags(), + title = { Text("Create Passkey (Mock)") }, + text = { + Text( + "Name: ${request.name}\n\n" + + "Playground-only simulation. On a real device this would trigger the " + + "Android Credential Manager passkey prompt. Tap Simulate to return " + + "mock credential data.", + ) + }, + confirmButton = { + Button( + onClick = { + request.result.complete(mockPasskeySigner(request.name)) + pendingRequest = null + }, + modifier = Modifier.semantics { testTag = "passkey-simulate-button" }, + ) { + Text("Simulate") + } + }, + dismissButton = { + TextButton( + onClick = { + request.result.complete(null) + pendingRequest = null + }, + ) { + Text("Cancel") + } + }, + ) + } + + return { name -> + val result = CompletableDeferred() + pendingRequest = PendingPasskeyRequest(name, result) + try { + result.await() + } finally { + pendingRequest = null + } + } +} + +private class PendingPasskeyRequest( + val name: String, + val result: CompletableDeferred, +) + +private fun mockPasskeySigner(name: String): DelegatedSigner.Passkey { + val random = SecureRandom() + + fun hex(byteCount: Int): String { + val bytes = ByteArray(byteCount).also { random.nextBytes(it) } + return bytes.joinToString("") { "%02x".format(it) } + } + + // The 0x prefix is required: the register-signer API validates publicKey.x/y as + // "a valid positive BigInt decimal or hex string" and rejects bare hex with HTTP 400. + // The server does not validate attestation/cryptography, so random values are + // accepted — mirrors the Flutter playground mock. + return DelegatedSigner.Passkey( + id = "mock-credential-${hex(8)}", + name = name, + publicKeyX = "0x${hex(32)}", + publicKeyY = "0x${hex(32)}", + ) +} + +// --------------------------------------------------------------------------- +// Real passkey creation via Android Credential Manager (default path). +// --------------------------------------------------------------------------- + private suspend fun createPasskeySigner( context: Context, name: String, diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/AuthMethodSelectionScreen.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/AuthMethodSelectionScreen.kt index 73de51c..2a66f35 100644 --- a/app/src/commonMain/kotlin/com/crossmint/kotlin/AuthMethodSelectionScreen.kt +++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/AuthMethodSelectionScreen.kt @@ -14,9 +14,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Surface @@ -102,12 +100,6 @@ fun AuthMethodSelectionScreen( } }, ) - - OrDivider() - - CheckoutButton(onClick = { - navController.navigate(Routes.Checkout) - }) } Spacer(modifier = Modifier.weight(1f)) @@ -227,56 +219,3 @@ fun SignInButton( } } } - -@Composable -fun OrDivider() { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - HorizontalDivider( - modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.outlineVariant, - ) - Text( - text = "OR", - modifier = Modifier.padding(horizontal = 16.dp), - fontSize = 14.sp, - ) - HorizontalDivider( - modifier = Modifier.weight(1f), - color = MaterialTheme.colorScheme.outlineVariant, - ) - } -} - -@Composable -fun CheckoutButton(onClick: () -> Unit) { - OutlinedButton( - onClick = { onClick() }, - modifier = - Modifier - .fillMaxWidth() - .height(52.dp), - shape = RoundedCornerShape(12.dp), - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.primary, - ), - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "Embedded Checkout", - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - ) - Text( - "Payment Flow", - fontSize = 12.sp, - fontWeight = FontWeight.Normal, - ) - } - } -} diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/DemoApp.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/DemoApp.kt index 5e39bd0..72dac14 100644 --- a/app/src/commonMain/kotlin/com/crossmint/kotlin/DemoApp.kt +++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/DemoApp.kt @@ -4,7 +4,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -15,12 +18,12 @@ import com.crossmint.kotlin.auth.bringyourown.BringYourOwnAuthScreen import com.crossmint.kotlin.auth.crossmint.CrossmintAuthViewModel import com.crossmint.kotlin.auth.crossmint.CrossmintOTPEmailScreen import com.crossmint.kotlin.auth.crossmint.CrossmintOTPVerificationScreen -import com.crossmint.kotlin.checkout.CheckoutScreen import com.crossmint.kotlin.compose.LocalCrossmintSDK import com.crossmint.kotlin.wallet.CreateWalletViewModel import com.crossmint.kotlin.wallet.WalletScreen import com.crossmint.kotlin.wallet.WalletViewModel import com.crossmint.kotlin.wallet.createwallet.CreateWalletScreen +import kotlinx.coroutines.launch // AuthMode is defined in androidMain, so we pass it as a parameter from AppRoot @Composable @@ -136,9 +139,44 @@ fun DemoApp( isStaging = isStaging, ) } + } - composable { - CheckoutScreen(navController = navController) - } + DeviceSignerOTPDialog( + walletViewModel = walletViewModel, + createWalletViewModel = createWalletViewModel, + ) +} + +@Composable +private fun DeviceSignerOTPDialog( + walletViewModel: WalletViewModel, + createWalletViewModel: CreateWalletViewModel, +) { + val sdk = LocalCrossmintSDK.current + val scope = rememberCoroutineScope() + var shouldShowOTP by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + sdk.isOTPRequired.collect { shouldShowOTP = it } + } + + if (shouldShowOTP) { + val walletUiState by walletViewModel.uiState.collectAsState() + val createWalletUiState by createWalletViewModel.uiState.collectAsState() + val signerType = + createWalletUiState.pendingOTPSignerType + ?: when (walletUiState.selectedSigner?.type?.lowercase()) { + "phone" -> OTPSignerType.PHONE + else -> OTPSignerType.EMAIL + } + OTPDialog( + signerType = signerType, + onOTPSubmit = { scope.launch { sdk.submit(it) } }, + onDismiss = { + scope.launch { sdk.cancelTransaction() } + walletViewModel.clearTransaction() + shouldShowOTP = false + }, + ) } } diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/Routes.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/Routes.kt index 1c8af63..b157346 100644 --- a/app/src/commonMain/kotlin/com/crossmint/kotlin/Routes.kt +++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/Routes.kt @@ -20,7 +20,4 @@ class Routes { @Serializable object CreateWallet - - @Serializable - object Checkout } diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/auth/bringyourown/BringYourOwnAuthViewModel.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/auth/bringyourown/BringYourOwnAuthViewModel.kt index 738e741..7c1c57b 100644 --- a/app/src/commonMain/kotlin/com/crossmint/kotlin/auth/bringyourown/BringYourOwnAuthViewModel.kt +++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/auth/bringyourown/BringYourOwnAuthViewModel.kt @@ -49,8 +49,6 @@ class BringYourOwnAuthViewModel( return@launch } - // This function doesn't actually validate the JWT against the JWKS endpoint, but my presumption is that it - // would. Right now we are kind of implicitly relying on the first getWallet api call to validate our JWT token. authManager.setJWT(jwt) showToast("Authentication successful") diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/checkout/CheckoutScreen.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/checkout/CheckoutScreen.kt deleted file mode 100644 index 95e07d8..0000000 --- a/app/src/commonMain/kotlin/com/crossmint/kotlin/checkout/CheckoutScreen.kt +++ /dev/null @@ -1,89 +0,0 @@ -package com.crossmint.kotlin.checkout - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.navigation.NavController -import com.crossmint.kotlin.checkout.models.CheckoutAllowedMethods -import com.crossmint.kotlin.checkout.models.CheckoutAppearance -import com.crossmint.kotlin.checkout.models.CheckoutAppearanceRules -import com.crossmint.kotlin.checkout.models.CheckoutCryptoPayment -import com.crossmint.kotlin.checkout.models.CheckoutDestinationInputRule -import com.crossmint.kotlin.checkout.models.CheckoutFiatPayment -import com.crossmint.kotlin.checkout.models.CheckoutPayment -import com.crossmint.kotlin.checkout.models.CheckoutReceiptEmailInputRule - -/** - * Demo screen showcasing the CrossmintEmbeddedCheckout component. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CheckoutScreen(navController: NavController) { - Column( - modifier = - Modifier - .fillMaxSize() - .background(Color.White), - ) { - TopAppBar( - title = { Text("Checkout") }, - navigationIcon = { - IconButton(onClick = { navController.popBackStack() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - ) - } - }, - ) - - Box( - modifier = - Modifier - .fillMaxWidth() - .weight(1f), - ) { - CrossmintEmbeddedCheckout( - orderId = "your-order-id", - clientSecret = "your-client-secret", - payment = - CheckoutPayment( - crypto = CheckoutCryptoPayment(enabled = false), - fiat = - CheckoutFiatPayment( - enabled = true, - allowedMethods = - CheckoutAllowedMethods( - googlePay = true, - applePay = true, - card = true, - ), - ), - receiptEmail = "robin+kotlin@crossmint.com", - ), - appearance = - CheckoutAppearance( - rules = - CheckoutAppearanceRules( - destinationInput = CheckoutDestinationInputRule(display = "hidden"), - receiptEmailInput = CheckoutReceiptEmailInputRule(display = "hidden"), - ), - ), - environment = CheckoutEnvironment.STAGING, - modifier = Modifier.fillMaxSize(), - ) - } - } -} diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/CreateWalletViewModel.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/CreateWalletViewModel.kt index eac4805..7e41d41 100644 --- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/CreateWalletViewModel.kt +++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/CreateWalletViewModel.kt @@ -3,6 +3,7 @@ package com.crossmint.kotlin.wallet import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.NavController +import com.crossmint.kotlin.OTPSignerType import com.crossmint.kotlin.signers.DelegatedSigner import com.crossmint.kotlin.signers.SignerType import com.crossmint.kotlin.types.Chain @@ -18,6 +19,7 @@ data class CreateWalletUiState( val isCreating: Boolean = false, val errorMessage: String? = null, val createdWallet: Wallet? = null, + val pendingOTPSignerType: OTPSignerType? = null, ) { val hasError: Boolean get() = errorMessage != null @@ -44,6 +46,16 @@ class CreateWalletViewModel( _uiState.value.copy( isCreating = true, errorMessage = null, + pendingOTPSignerType = + if (deviceSigner) { + when (signer) { + is SignerType.Email -> OTPSignerType.EMAIL + is SignerType.Phone -> OTPSignerType.PHONE + SignerType.ApiKey, is SignerType.Passkey -> null + } + } else { + null + }, ) when ( @@ -56,11 +68,15 @@ class CreateWalletViewModel( ) ) { is Result.Success -> { + if (signer is SignerType.Phone) { + WalletEvents.rememberPhoneChannel(signer.phoneNumber, signer.channel) + } _uiState.value = _uiState.value.copy( createdWallet = result.value, isCreating = false, errorMessage = null, + pendingOTPSignerType = null, ) // Signal WalletViewModel to refresh WalletEvents.notifyWalletCreated() @@ -72,6 +88,7 @@ class CreateWalletViewModel( _uiState.value.copy( isCreating = false, errorMessage = "Failed to create wallet: ${result.error.message}", + pendingOTPSignerType = null, ) } } diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletEvents.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletEvents.kt index 72a2a28..dce8ce0 100644 --- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletEvents.kt +++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletEvents.kt @@ -1,5 +1,6 @@ package com.crossmint.kotlin.wallet +import com.crossmint.kotlin.signers.OTPDeliveryChannel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -24,4 +25,26 @@ object WalletEvents { fun notifyWalletCreated() { _walletCreated.tryEmit(Unit) } + + /** + * The OTP delivery channel chosen for a phone signer, by phone number. + * + * The wallet API never returns this, so a wallet fetched with getWallet always falls back to + * SMS. The demo keeps the choice here and reapplies it with useSigner on every wallet load. + */ + private val phoneChannels = mutableMapOf() + + fun rememberPhoneChannel( + phoneNumber: String, + channel: OTPDeliveryChannel?, + ) { + if (channel != null) phoneChannels[phoneNumber] = channel + } + + fun phoneChannel(phoneNumber: String): OTPDeliveryChannel? = phoneChannels[phoneNumber] + + /** Called on sign out, so a channel never carries over to the next session. */ + fun clearPhoneChannels() { + phoneChannels.clear() + } } diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletScreen.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletScreen.kt index 52d10d9..5a54a66 100644 --- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletScreen.kt +++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletScreen.kt @@ -65,10 +65,7 @@ import com.crossmint.crossmintdemoapp.generated.resources.ic_crossmint import com.crossmint.crossmintdemoapp.generated.resources.ic_eth import com.crossmint.crossmintdemoapp.generated.resources.ic_solana import com.crossmint.crossmintdemoapp.generated.resources.ic_stellar -import com.crossmint.kotlin.CrossmintSDK import com.crossmint.kotlin.Deps -import com.crossmint.kotlin.OTPDialog -import com.crossmint.kotlin.OTPSignerType import com.crossmint.kotlin.Routes import com.crossmint.kotlin.auth.AuthMethod import com.crossmint.kotlin.auth.crossmint.CrossmintAuthViewModel @@ -108,14 +105,9 @@ fun WalletScreen( var showSignersSheet by remember { mutableStateOf(false) } var showActivitySheet by remember { mutableStateOf(false) } var showSigningSheet by remember { mutableStateOf(false) } - val shouldShowOTP = remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) } val passkeyCreator = rememberPasskeyCreator() - LaunchedEffect(Unit) { - CrossmintSDK.shared.isOTPRequired.collect { shouldShowOTP.value = it } - } - LaunchedEffect(Unit) { walletViewModel.sessionExpired.collect { when (authMethod) { @@ -411,23 +403,6 @@ fun WalletScreen( onDismiss = { showSigningSheet = false }, ) } - - if (shouldShowOTP.value) { - val signerType = - when (uiState.selectedSigner?.type?.lowercase()) { - "phone" -> OTPSignerType.PHONE - else -> OTPSignerType.EMAIL - } - OTPDialog( - signerType = signerType, - onOTPSubmit = { scope.launch { CrossmintSDK.shared.submit(it) } }, - onDismiss = { - scope.launch { CrossmintSDK.shared.cancelTransaction() } - walletViewModel.clearTransaction() - shouldShowOTP.value = false - }, - ) - } } } diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletViewModel.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletViewModel.kt index 0e1ac6e..350e4da 100644 --- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletViewModel.kt +++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/WalletViewModel.kt @@ -64,6 +64,12 @@ class WalletViewModel( @OptIn(ExperimentalUuidApi::class) private fun newIdempotencyKey(): String = Uuid.random().toString() + private suspend fun reapplyPhoneChannel(wallet: Wallet) { + val admin = wallet.config.adminSigner as? SignerData.Phone ?: return + val channel = WalletEvents.phoneChannel(admin.phone) ?: return + wallet.useSigner(DelegatedSigner.Phone(admin.phone, channel = channel)) + } + fun fetchWallet(chain: Chain) { val supportedChain = SupportedChain.entries.find { it.chain == chain } ?: return viewModelScope.launch { @@ -82,6 +88,7 @@ class WalletViewModel( ) { val signers = buildAvailableSigners(wallet) val securityLevel = crossmintWallets.getDeviceSignerSecurityLevel(wallet.address) + reapplyPhoneChannel(wallet) walletCache[supportedChain] = CachedWallet(wallet, securityLevel) notFoundChains.remove(supportedChain) activeFetches.remove(supportedChain) @@ -154,6 +161,7 @@ class WalletViewModel( is Result.Success -> { val wallet = result.value val securityLevel = crossmintWallets.getDeviceSignerSecurityLevel(wallet.address) + reapplyPhoneChannel(wallet) walletCache[chain] = CachedWallet(wallet, securityLevel) } is Result.Failure -> { @@ -205,6 +213,9 @@ class WalletViewModel( _uiState.value = _uiState.value.copy(isCreatingWallet = true, errorMessage = null) when (val result = crossmintWallets.createWallet(chain, signer, delegatedSigners)) { is Result.Success -> { + if (signer is SignerType.Phone) { + WalletEvents.rememberPhoneChannel(signer.phoneNumber, signer.channel) + } val wallet = result.value val signers = buildAvailableSigners(wallet) val supportedChain = SupportedChain.entries.find { it.chain == chain } @@ -315,6 +326,7 @@ class WalletViewModel( walletCache.clear() notFoundChains.clear() activeFetches.clear() + WalletEvents.clearPhoneChannels() _uiState.value = WalletUiState() } diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/AdminSignerCard.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/AdminSignerCard.kt index 3e0a972..5ea2ddc 100644 --- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/AdminSignerCard.kt +++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/AdminSignerCard.kt @@ -17,10 +17,14 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -31,7 +35,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.crossmint.kotlin.signers.OTPDeliveryChannel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AdminSignerCard( selectedType: AdminSignerType, @@ -40,6 +46,8 @@ fun AdminSignerCard( onEmailChange: (String) -> Unit, phone: String, onPhoneChange: (String) -> Unit, + phoneChannel: OTPDeliveryChannel, + onPhoneChannelChange: (OTPDeliveryChannel) -> Unit, ) { var expanded by remember { mutableStateOf(false) } @@ -131,6 +139,28 @@ fun AdminSignerCard( ), singleLine = true, ) + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = "OTP delivery", + fontSize = 12.sp, + ) + Spacer(modifier = Modifier.height(4.dp)) + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + OTPDeliveryChannel.entries.forEachIndexed { index, channel -> + SegmentedButton( + selected = phoneChannel == channel, + onClick = { onPhoneChannelChange(channel) }, + shape = + SegmentedButtonDefaults.itemShape( + index = index, + count = OTPDeliveryChannel.entries.size, + ), + ) { Text(channel.displayName) } + } + } } AdminSignerType.API_KEY -> { Text( diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletScreen.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletScreen.kt index 49afa6b..dd6c0df 100644 --- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletScreen.kt +++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.crossmint.kotlin.signers.DelegatedSigner +import com.crossmint.kotlin.signers.OTPDeliveryChannel import com.crossmint.kotlin.signers.SignerType import com.crossmint.kotlin.types.Chain import com.crossmint.kotlin.wallet.CreateWalletViewModel @@ -58,6 +59,7 @@ fun CreateWalletScreen( var selectedAdminSignerType by remember { mutableStateOf(AdminSignerType.EMAIL) } var adminEmail by remember { mutableStateOf(userEmail ?: "") } var adminPhone by remember { mutableStateOf("") } + var adminPhoneChannel by remember { mutableStateOf(OTPDeliveryChannel.SMS) } val delegatedSigners = remember { mutableStateListOf() } @@ -99,7 +101,8 @@ fun CreateWalletScreen( val adminSigner = when (selectedAdminSignerType) { AdminSignerType.EMAIL -> SignerType.Email(adminEmail) - AdminSignerType.PHONE -> SignerType.Phone(adminPhone) + AdminSignerType.PHONE -> + SignerType.Phone(adminPhone, channel = adminPhoneChannel) AdminSignerType.API_KEY -> SignerType.ApiKey } val deviceEntry = delegatedSigners.firstOrNull { it.type == DelegatedSignerType.DEVICE } @@ -182,6 +185,8 @@ fun CreateWalletScreen( onEmailChange = { adminEmail = it }, phone = adminPhone, onPhoneChange = { adminPhone = it }, + phoneChannel = adminPhoneChannel, + onPhoneChannelChange = { adminPhoneChannel = it }, ) Spacer(modifier = Modifier.height(32.dp)) diff --git a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletTypes.kt b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletTypes.kt index 4c39975..2e4af49 100644 --- a/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletTypes.kt +++ b/app/src/commonMain/kotlin/com/crossmint/kotlin/wallet/createwallet/CreateWalletTypes.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.Key import androidx.compose.material.icons.filled.Phone import androidx.compose.material.icons.filled.Smartphone import androidx.compose.ui.graphics.vector.ImageVector +import com.crossmint.kotlin.signers.OTPDeliveryChannel enum class AdminSignerType( val displayName: String, @@ -16,6 +17,13 @@ enum class AdminSignerType( API_KEY("API Key"), } +val OTPDeliveryChannel.displayName: String + get() = + when (this) { + OTPDeliveryChannel.SMS -> "SMS" + OTPDeliveryChannel.WHATSAPP -> "WhatsApp" + } + enum class DelegatedSignerType( val displayName: String, val icon: ImageVector, diff --git a/checkout/.gitignore b/checkout/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/checkout/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/checkout/build.gradle.kts b/checkout/build.gradle.kts new file mode 100644 index 0000000..de36285 --- /dev/null +++ b/checkout/build.gradle.kts @@ -0,0 +1,124 @@ +import java.util.Properties +import org.jetbrains.compose.resources.ResourcesExtension.ResourceClassGeneration +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidApplication) + alias(libs.plugins.buildkonfig) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + freeCompilerArgs.add("-Xexpect-actual-classes") + } + } + + sourceSets { + androidMain.dependencies { + implementation(compose.preview) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.appcompat) + implementation(libs.ktor.client.android) + implementation(libs.crossmint.identity) + } + + commonMain.dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(compose.materialIconsExtended) + implementation(libs.androidx.lifecycle.viewmodelCompose) + implementation(libs.androidx.lifecycle.runtimeCompose) + + implementation(libs.crossmint.core) + implementation(libs.crossmint.checkout) + + implementation(libs.material3.adaptive) + implementation(libs.material3.adaptive.layout) + implementation(libs.material3.adaptive.navigation) + implementation(libs.material3.adaptive.navigation.suite) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.kotlinx.serialization.json) + + implementation(libs.navigation.compose) + } + } +} + +val localProperties = Properties() +val localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) { + localProperties.load(localPropertiesFile.inputStream()) +} + +android { + namespace = "com.crossmint.kotlin.checkoutdemo" + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + + defaultConfig { + applicationId = "com.crossmint.checkoutdemo" + minSdk = + libs.versions.android.minSdk + .get() + .toInt() + targetSdk = + libs.versions.android.targetSdk + .get() + .toInt() + versionCode = 1 + versionName = "1.0" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} + +buildkonfig { + packageName = "com.crossmint.checkoutdemo" + + defaultConfigs { + buildConfigField( + com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING, + "CROSSMINT_API_KEY", + localProperties.getProperty("crossmint_api_key", ""), + ) + } +} + +dependencies { + debugImplementation(compose.uiTooling) +} + +compose.resources { + publicResClass = true + generateResClass = ResourceClassGeneration.Always + packageOfResClass = "com.crossmint.crossmintcheckoutdemoapp.generated.resources" +} diff --git a/checkout/src/androidMain/AndroidManifest.xml b/checkout/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..1546d2a --- /dev/null +++ b/checkout/src/androidMain/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/checkout/src/androidMain/kotlin/com/crossmint/kotlin/checkoutdemo/MainActivity.kt b/checkout/src/androidMain/kotlin/com/crossmint/kotlin/checkoutdemo/MainActivity.kt new file mode 100644 index 0000000..d0a401d --- /dev/null +++ b/checkout/src/androidMain/kotlin/com/crossmint/kotlin/checkoutdemo/MainActivity.kt @@ -0,0 +1,18 @@ +package com.crossmint.kotlin.checkoutdemo + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import com.crossmint.checkoutdemo.BuildKonfig + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + setContent { + AppRoot(apiKey = BuildKonfig.CROSSMINT_API_KEY) + } + } +} diff --git a/checkout/src/androidMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/IdentityPreview.kt b/checkout/src/androidMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/IdentityPreview.kt new file mode 100644 index 0000000..24a2bc9 --- /dev/null +++ b/checkout/src/androidMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/IdentityPreview.kt @@ -0,0 +1,40 @@ +package com.crossmint.kotlin.checkoutdemo.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.crossmint.kotlin.identity.CrossmintIdentityVerification +import com.crossmint.kotlin.identity.IdentityVerificationCredentials + +@Composable +actual fun IdentityPreview( + apiKey: String, + inquiryId: String, + sessionToken: String, + modifier: Modifier, + onEvent: (String) -> Unit, +) { + if (inquiryId.isBlank()) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Enter an inquiry ID to preview identity verification.") + } + return + } + + CrossmintIdentityVerification( + apiKey = apiKey, + credentials = + IdentityVerificationCredentials( + inquiryId = inquiryId, + sessionToken = sessionToken.ifBlank { null }, + ), + modifier = modifier, + onReady = { onEvent("Identity verification ready") }, + onComplete = { status -> onEvent("Identity verification completed: $status") }, + onCancel = { onEvent("Identity verification cancelled") }, + onError = { error -> onEvent("Identity verification error: ${error.message}") }, + ) +} diff --git a/checkout/src/androidMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/PaneBackHandler.kt b/checkout/src/androidMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/PaneBackHandler.kt new file mode 100644 index 0000000..d7bb47d --- /dev/null +++ b/checkout/src/androidMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/PaneBackHandler.kt @@ -0,0 +1,13 @@ +package com.crossmint.kotlin.checkoutdemo.ui + +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldPredictiveBackHandler +import androidx.compose.runtime.Composable + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +actual fun PaneBackHandler(navigator: ThreePaneScaffoldNavigator) { + ThreePaneScaffoldPredictiveBackHandler(navigator, BackNavigationBehavior.PopUntilScaffoldValueChange) +} diff --git a/checkout/src/androidMain/kotlin/com/crossmint/kotlin/checkoutdemo/utility/Time.kt b/checkout/src/androidMain/kotlin/com/crossmint/kotlin/checkoutdemo/utility/Time.kt new file mode 100644 index 0000000..efd292e --- /dev/null +++ b/checkout/src/androidMain/kotlin/com/crossmint/kotlin/checkoutdemo/utility/Time.kt @@ -0,0 +1,12 @@ +package com.crossmint.kotlin.checkoutdemo.utility + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +actual fun currentTimeMillis(): Long = System.currentTimeMillis() + +actual fun formatTimestamp(timestamp: Long): String { + val formatter = SimpleDateFormat("HH:mm:ss", Locale.getDefault()) + return formatter.format(Date(timestamp)) +} diff --git a/checkout/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/checkout/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..8bf9ebf --- /dev/null +++ b/checkout/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/checkout/src/androidMain/res/drawable/ic_launcher_background.xml b/checkout/src/androidMain/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..05399f2 --- /dev/null +++ b/checkout/src/androidMain/res/drawable/ic_launcher_background.xml @@ -0,0 +1,10 @@ + + + + diff --git a/checkout/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/checkout/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/checkout/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/checkout/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/checkout/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/checkout/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/checkout/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/checkout/src/androidMain/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..84fa193 Binary files /dev/null and b/checkout/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ diff --git a/checkout/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/checkout/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..84fa193 Binary files /dev/null and b/checkout/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/checkout/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/checkout/src/androidMain/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..9704cde Binary files /dev/null and b/checkout/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ diff --git a/checkout/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/checkout/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..9704cde Binary files /dev/null and b/checkout/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/checkout/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/checkout/src/androidMain/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..d336797 Binary files /dev/null and b/checkout/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/checkout/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/checkout/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d336797 Binary files /dev/null and b/checkout/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/checkout/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/checkout/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..217426a Binary files /dev/null and b/checkout/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/checkout/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/checkout/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..217426a Binary files /dev/null and b/checkout/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/checkout/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/checkout/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..cc36b76 Binary files /dev/null and b/checkout/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/checkout/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/checkout/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..cc36b76 Binary files /dev/null and b/checkout/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/checkout/src/androidMain/res/values/strings.xml b/checkout/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000..10307d0 --- /dev/null +++ b/checkout/src/androidMain/res/values/strings.xml @@ -0,0 +1,3 @@ + + Checkout Playground + diff --git a/checkout/src/androidMain/res/xml/network_security_config.xml b/checkout/src/androidMain/res/xml/network_security_config.xml new file mode 100644 index 0000000..1bd94b6 --- /dev/null +++ b/checkout/src/androidMain/res/xml/network_security_config.xml @@ -0,0 +1,3 @@ + + + diff --git a/checkout/src/commonMain/composeResources/drawable/crossmint_logo.png b/checkout/src/commonMain/composeResources/drawable/crossmint_logo.png new file mode 100644 index 0000000..d54fbfd Binary files /dev/null and b/checkout/src/commonMain/composeResources/drawable/crossmint_logo.png differ diff --git a/checkout/src/commonMain/composeResources/drawable/ic_crossmint.xml b/checkout/src/commonMain/composeResources/drawable/ic_crossmint.xml new file mode 100644 index 0000000..4927ff5 --- /dev/null +++ b/checkout/src/commonMain/composeResources/drawable/ic_crossmint.xml @@ -0,0 +1,9 @@ + + + diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/AppRoot.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/AppRoot.kt new file mode 100644 index 0000000..51e4c3e --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/AppRoot.kt @@ -0,0 +1,22 @@ +package com.crossmint.kotlin.checkoutdemo + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.crossmint.kotlin.checkoutdemo.data.OrdersApi +import com.crossmint.kotlin.checkoutdemo.data.createHttpClient +import com.crossmint.kotlin.checkoutdemo.ui.CheckoutDemoApp +import com.crossmint.kotlin.checkoutdemo.ui.CheckoutDemoTheme + +@Composable +fun AppRoot(apiKey: String) { + CheckoutDemoTheme { + val viewModel = + remember { + PlaygroundViewModel( + ordersApi = OrdersApi(createHttpClient()), + apiKey = apiKey, + ) + } + CheckoutDemoApp(viewModel = viewModel, apiKey = apiKey) + } +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/PlaygroundUiState.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/PlaygroundUiState.kt new file mode 100644 index 0000000..d621647 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/PlaygroundUiState.kt @@ -0,0 +1,29 @@ +package com.crossmint.kotlin.checkoutdemo + +import com.crossmint.kotlin.checkoutdemo.model.DemoEvent +import com.crossmint.kotlin.checkoutdemo.model.OrderDraft +import com.crossmint.kotlin.checkoutdemo.model.OrderSession +import com.crossmint.kotlin.checkoutdemo.model.PlaygroundOptions +import com.crossmint.kotlin.checkoutdemo.model.PlaygroundSection + +data class PlaygroundUiState( + val section: PlaygroundSection = PlaygroundSection.ORDER, + val draft: OrderDraft = OrderDraft(), + val options: PlaygroundOptions = PlaygroundOptions(), + val existingOrderId: String = "", + val existingClientSecret: String = "", + val identityInquiryId: String = "", + val identitySessionToken: String = "", + val confirmedIdentityInquiryId: String = "", + val confirmedIdentitySessionToken: String = "", + val session: OrderSession? = null, + val isCreatingOrder: Boolean = false, + val orderErrorMessage: String? = null, + val events: List = emptyList(), +) { + val isPreviewingCheckout: Boolean + get() = session != null + + val isPreviewingIdentity: Boolean + get() = confirmedIdentityInquiryId.isNotBlank() +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/PlaygroundViewModel.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/PlaygroundViewModel.kt new file mode 100644 index 0000000..acec59e --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/PlaygroundViewModel.kt @@ -0,0 +1,113 @@ +package com.crossmint.kotlin.checkoutdemo + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.crossmint.kotlin.checkoutdemo.data.OrdersApi +import com.crossmint.kotlin.checkoutdemo.model.DemoEvent +import com.crossmint.kotlin.checkoutdemo.model.OrderDraft +import com.crossmint.kotlin.checkoutdemo.model.OrderSession +import com.crossmint.kotlin.checkoutdemo.model.PlaygroundOptions +import com.crossmint.kotlin.checkoutdemo.model.PlaygroundSection +import com.crossmint.kotlin.checkoutdemo.utility.currentTimeMillis +import com.crossmint.kotlin.types.Result +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class PlaygroundViewModel( + private val ordersApi: OrdersApi, + private val apiKey: String, +) : ViewModel() { + private val _uiState = MutableStateFlow(PlaygroundUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun selectSection(section: PlaygroundSection) { + _uiState.value = _uiState.value.copy(section = section) + } + + fun updateDraft(draft: OrderDraft) { + _uiState.value = _uiState.value.copy(draft = draft) + } + + fun updateOptions(options: PlaygroundOptions) { + _uiState.value = _uiState.value.copy(options = options) + } + + fun updateExistingOrder( + orderId: String, + clientSecret: String, + ) { + _uiState.value = _uiState.value.copy(existingOrderId = orderId, existingClientSecret = clientSecret) + } + + fun updateIdentityCredentials( + inquiryId: String, + sessionToken: String, + ) { + _uiState.value = _uiState.value.copy(identityInquiryId = inquiryId, identitySessionToken = sessionToken) + } + + fun startIdentityPreview() { + val state = _uiState.value + _uiState.value = + state.copy( + confirmedIdentityInquiryId = state.identityInquiryId, + confirmedIdentitySessionToken = state.identitySessionToken, + ) + } + + fun useExistingOrder() { + val state = _uiState.value + val orderId = state.existingOrderId.trim() + val clientSecret = state.existingClientSecret.trim() + if (orderId.isEmpty() || clientSecret.isEmpty()) return + + setSession( + OrderSession(orderId = orderId, clientSecret = clientSecret, source = OrderSession.Source.EXISTING), + "Using existing order $orderId", + ) + } + + fun createOrder() { + val draft = _uiState.value.draft + _uiState.value = _uiState.value.copy(isCreatingOrder = true, orderErrorMessage = null) + + viewModelScope.launch { + when (val result = ordersApi.createOrder(apiKey, draft)) { + is Result.Success -> { + _uiState.value = _uiState.value.copy(isCreatingOrder = false) + setSession(result.value, "Order ${result.value.orderId} created") + } + + is Result.Failure -> { + _uiState.value = + _uiState.value.copy( + isCreatingOrder = false, + orderErrorMessage = result.error.message, + ) + logEvent("Order creation failed: ${result.error.message}") + } + } + } + } + + fun clearSession() { + _uiState.value = _uiState.value.copy(session = null) + } + + fun logExternalEvent(message: String) = logEvent(message) + + private fun setSession( + session: OrderSession, + eventMessage: String, + ) { + _uiState.value = _uiState.value.copy(session = session) + logEvent(eventMessage) + } + + private fun logEvent(message: String) { + val event = DemoEvent(timestamp = currentTimeMillis(), message = message) + _uiState.value = _uiState.value.copy(events = _uiState.value.events + event) + } +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/data/HttpClientFactory.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/data/HttpClientFactory.kt new file mode 100644 index 0000000..ac0c3c9 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/data/HttpClientFactory.kt @@ -0,0 +1,21 @@ +package com.crossmint.kotlin.checkoutdemo.data + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +val checkoutDemoJson = + Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = true + explicitNulls = false + } + +fun createHttpClient(): HttpClient = + HttpClient { + install(ContentNegotiation) { + json(checkoutDemoJson) + } + } diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/data/OrderApiError.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/data/OrderApiError.kt new file mode 100644 index 0000000..93c6a50 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/data/OrderApiError.kt @@ -0,0 +1,19 @@ +package com.crossmint.kotlin.checkoutdemo.data + +sealed interface OrderApiError { + val message: String + + data class Network( + val cause: Throwable, + ) : OrderApiError { + override val message: String = cause.message ?: "The Orders API request failed." + } + + data class Api( + override val message: String, + ) : OrderApiError + + data class UnexpectedResponse( + override val message: String = "The Orders API returned a response the demo could not read.", + ) : OrderApiError +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/data/OrdersApi.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/data/OrdersApi.kt new file mode 100644 index 0000000..7ed42c8 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/data/OrdersApi.kt @@ -0,0 +1,139 @@ +package com.crossmint.kotlin.checkoutdemo.data + +import com.crossmint.kotlin.checkout.CheckoutEnvironment +import com.crossmint.kotlin.checkoutdemo.model.OrderDraft +import com.crossmint.kotlin.checkoutdemo.model.OrderSession +import com.crossmint.kotlin.core.ApiKey +import com.crossmint.kotlin.core.Environment +import com.crossmint.kotlin.types.Result +import io.ktor.client.HttpClient +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlin.coroutines.cancellation.CancellationException +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +class OrdersApi( + private val httpClient: HttpClient, + private val json: Json = checkoutDemoJson, +) { + suspend fun createOrder( + apiKey: String, + draft: OrderDraft, + ): Result { + val parsedKey = + try { + ApiKey.fromString(apiKey) + } catch (e: Exception) { + return Result.Failure(OrderApiError.Api(e.message ?: "Invalid API key.")) + } + + val requestBody = + CreateOrderRequest( + payment = + OrderPayment( + receiptEmail = draft.receiptEmail.trim().ifEmpty { null }, + ), + lineItems = + OrderLineItems( + tokenLocator = draft.tokenLocator.trim(), + executionParameters = OrderExecutionParameters(amount = draft.amount.trim()), + ), + recipient = OrderRecipient(walletAddress = draft.recipientWalletAddress.trim()), + ) + + return try { + val response = + httpClient.post("https://${parsedKey.host}/api/2022-06-09/orders") { + header("X-API-KEY", apiKey) + contentType(ContentType.Application.Json) + setBody(requestBody) + } + val raw = response.bodyAsText() + val parsed = json.decodeFromString(CreateOrderResponse.serializer(), raw) + + val orderId = parsed.order?.orderId + val clientSecret = parsed.clientSecret + if (orderId != null && clientSecret != null) { + Result.Success( + OrderSession( + orderId = orderId, + clientSecret = clientSecret, + source = OrderSession.Source.CREATED_IN_APP, + ), + ) + } else if (parsed.error == true) { + Result.Failure(OrderApiError.Api(parsed.message ?: "The Orders API rejected the request.")) + } else { + Result.Failure(OrderApiError.UnexpectedResponse()) + } + } catch (e: CancellationException) { + throw e + } catch (e: SerializationException) { + Result.Failure(OrderApiError.UnexpectedResponse()) + } catch (e: Exception) { + Result.Failure(OrderApiError.Network(e)) + } + } +} + +internal val ApiKey.host: String + get() = "${apiEnvironmentPathComponent}crossmint.com" + +internal val ApiKey.checkoutEnvironment: CheckoutEnvironment + get() = + when (environment) { + Environment.PRODUCTION -> CheckoutEnvironment.PRODUCTION + Environment.STAGING, Environment.DEVELOPMENT -> CheckoutEnvironment.STAGING + } + +@Serializable +private data class CreateOrderRequest( + val payment: OrderPayment, + val lineItems: OrderLineItems, + val recipient: OrderRecipient, +) + +@Serializable +private data class OrderPayment( + val method: String = "card", + val currency: String = "usd", + val receiptEmail: String? = null, +) + +@Serializable +private data class OrderLineItems( + val tokenLocator: String, + val executionParameters: OrderExecutionParameters, +) + +@Serializable +private data class OrderExecutionParameters( + val mode: String = "exact-in", + val amount: String, +) + +@Serializable +private data class OrderRecipient( + val walletAddress: String, +) + +@Serializable +private data class CreateOrderResponse( + val error: Boolean? = null, + val message: String? = null, + val clientSecret: String? = null, + val order: CreateOrderResponseOrder? = null, +) + +@Serializable +private data class CreateOrderResponseOrder( + @SerialName("orderId") + val orderId: String? = null, +) diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/DemoEvent.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/DemoEvent.kt new file mode 100644 index 0000000..4fba725 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/DemoEvent.kt @@ -0,0 +1,6 @@ +package com.crossmint.kotlin.checkoutdemo.model + +data class DemoEvent( + val timestamp: Long, + val message: String, +) diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/OrderDraft.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/OrderDraft.kt new file mode 100644 index 0000000..5ba6158 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/OrderDraft.kt @@ -0,0 +1,8 @@ +package com.crossmint.kotlin.checkoutdemo.model + +data class OrderDraft( + val tokenLocator: String = "", + val amount: String = "1", + val recipientWalletAddress: String = "", + val receiptEmail: String = "", +) diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/OrderSession.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/OrderSession.kt new file mode 100644 index 0000000..22d0a89 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/OrderSession.kt @@ -0,0 +1,12 @@ +package com.crossmint.kotlin.checkoutdemo.model + +data class OrderSession( + val orderId: String, + val clientSecret: String, + val source: Source, +) { + enum class Source { + CREATED_IN_APP, + EXISTING, + } +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/PlaygroundOptions.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/PlaygroundOptions.kt new file mode 100644 index 0000000..d93135f --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/PlaygroundOptions.kt @@ -0,0 +1,136 @@ +package com.crossmint.kotlin.checkoutdemo.model + +import com.crossmint.kotlin.checkout.IdentityVerificationHandling +import com.crossmint.kotlin.checkout.models.CheckoutAllowedMethods +import com.crossmint.kotlin.checkout.models.CheckoutAppearance +import com.crossmint.kotlin.checkout.models.CheckoutAppearanceRules +import com.crossmint.kotlin.checkout.models.CheckoutAppearanceVariables +import com.crossmint.kotlin.checkout.models.CheckoutCryptoPayment +import com.crossmint.kotlin.checkout.models.CheckoutDestinationInputRule +import com.crossmint.kotlin.checkout.models.CheckoutFiatPayment +import com.crossmint.kotlin.checkout.models.CheckoutGlobalMessageRule +import com.crossmint.kotlin.checkout.models.CheckoutInputRule +import com.crossmint.kotlin.checkout.models.CheckoutPayment +import com.crossmint.kotlin.checkout.models.CheckoutPrimaryButtonRule +import com.crossmint.kotlin.checkout.models.CheckoutReceiptEmailInputRule +import com.crossmint.kotlin.checkout.models.CheckoutTabRule +import com.crossmint.kotlin.checkout.models.CheckoutVariablesColorStyle + +data class PlaygroundOptions( + val cryptoEnabled: Boolean = false, + val fiatEnabled: Boolean = true, + val allowCard: Boolean = true, + val allowGooglePay: Boolean = true, + val showsDestinationInput: Boolean = true, + val showsReceiptEmailInput: Boolean = true, + val showsStatusMessage: Boolean = true, + val textColor: String = "", + val backgroundColor: String = "", + val accentColor: String = "", + val dangerColor: String = "", + val inputRadius: Float = DEFAULT_BORDER_RADIUS, + val tabRadius: Float = DEFAULT_BORDER_RADIUS, + val buttonRadius: Float = DEFAULT_BORDER_RADIUS, + val externalIdentityVerification: Boolean = true, +) { + fun toCheckoutPayment(receiptEmail: String): CheckoutPayment = + CheckoutPayment( + crypto = CheckoutCryptoPayment(enabled = cryptoEnabled), + fiat = + CheckoutFiatPayment( + enabled = fiatEnabled, + allowedMethods = + CheckoutAllowedMethods( + googlePay = allowGooglePay, + applePay = false, + card = allowCard, + ), + ), + receiptEmail = receiptEmail.ifBlank { null }, + ) + + fun toCheckoutAppearance(): CheckoutAppearance? { + val colors = + if (textColor.isBlank() && backgroundColor.isBlank() && accentColor.isBlank() && dangerColor.isBlank()) { + null + } else { + CheckoutVariablesColorStyle( + textPrimary = textColor.ifBlank { null }, + backgroundPrimary = backgroundColor.ifBlank { null }, + accent = accentColor.ifBlank { null }, + danger = dangerColor.ifBlank { null }, + ) + } + + val variables = if (colors == null) null else CheckoutAppearanceVariables(colors = colors) + + val hidesElement = !showsDestinationInput || !showsReceiptEmailInput || !showsStatusMessage + val hasCustomInputRadius = inputRadius != DEFAULT_BORDER_RADIUS + val hasCustomTabRadius = tabRadius != DEFAULT_BORDER_RADIUS + val hasCustomButtonRadius = buttonRadius != DEFAULT_BORDER_RADIUS + + val rules = + if (hidesElement || hasCustomInputRadius || hasCustomTabRadius || hasCustomButtonRadius) { + CheckoutAppearanceRules( + destinationInput = + if (showsDestinationInput) { + null + } else { + CheckoutDestinationInputRule( + display = "hidden", + ) + }, + receiptEmailInput = + if (showsReceiptEmailInput) { + null + } else { + CheckoutReceiptEmailInputRule( + display = "hidden", + ) + }, + globalMessage = + if (showsStatusMessage) { + null + } else { + CheckoutGlobalMessageRule( + display = "hidden", + ) + }, + input = + if (hasCustomInputRadius) { + CheckoutInputRule( + borderRadius = "${inputRadius.toInt()}px", + ) + } else { + null + }, + tab = + if (hasCustomTabRadius) { + CheckoutTabRule( + borderRadius = "${tabRadius.toInt()}px", + ) + } else { + null + }, + primaryButton = + if (hasCustomButtonRadius) { + CheckoutPrimaryButtonRule(borderRadius = "${buttonRadius.toInt()}px") + } else { + null + }, + ) + } else { + null + } + + if (variables == null && rules == null) return null + return CheckoutAppearance(rules = rules, variables = variables) + } + + fun toIdentityVerificationHandling(): IdentityVerificationHandling? = + if (externalIdentityVerification) IdentityVerificationHandling.EXTERNAL else null + + companion object { + const val DEFAULT_BORDER_RADIUS = 8f + } +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/PlaygroundSection.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/PlaygroundSection.kt new file mode 100644 index 0000000..b7c78ce --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/PlaygroundSection.kt @@ -0,0 +1,21 @@ +package com.crossmint.kotlin.checkoutdemo.model + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CreditCard +import androidx.compose.material.icons.filled.Palette +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.ShoppingBag +import androidx.compose.material.icons.filled.TextFields +import androidx.compose.ui.graphics.vector.ImageVector + +enum class PlaygroundSection( + val title: String, + val navLabel: String, + val icon: ImageVector, +) { + ORDER("Order", "Order", Icons.Filled.ShoppingBag), + PAYMENT("Payment", "Payment", Icons.Filled.CreditCard), + APPEARANCE("Appearance", "Appearance", Icons.Filled.Palette), + FIELDS("Fields", "Fields", Icons.Filled.TextFields), + IDENTITY("Identity verification", "Identity", Icons.Filled.Person), +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/TokenPreset.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/TokenPreset.kt new file mode 100644 index 0000000..0dfe30c --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/model/TokenPreset.kt @@ -0,0 +1,105 @@ +package com.crossmint.kotlin.checkoutdemo.model + +import com.crossmint.kotlin.core.Environment + +data class TokenNetwork( + val chain: String, + val network: String, + val locator: String, +) { + val title: String get() = "$chain ($network)" +} + +data class TokenPreset( + val symbol: String, + val networks: List, +) { + companion object { + private val stagingPresets = + listOf( + TokenPreset( + symbol = "USDC", + networks = + listOf( + TokenNetwork("Solana", "Devnet", "solana:4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"), + TokenNetwork("Base", "Sepolia", "base-sepolia:0x036CbD53842c5426634e7929541eC2318f3dCF7e"), + TokenNetwork( + "Stellar", + "Testnet", + "stellar:CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", + ), + TokenNetwork("Polygon", "Amoy", "polygon-amoy:0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582"), + ), + ), + TokenPreset( + symbol = "EURC", + networks = + listOf( + TokenNetwork("Solana", "Devnet", "solana:HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr"), + TokenNetwork("Base", "Sepolia", "base-sepolia:0x808456652fdb597867f38412077A9182bf77359F"), + TokenNetwork( + "Stellar", + "Testnet", + "stellar:CCUUDM434BMZMYWYDITHFXHDMIVTGGD6T2I5UKNX5BSLXLW7HVR4MCGZ", + ), + ), + ), + TokenPreset( + symbol = "Wirex USDC", + networks = + listOf( + TokenNetwork( + "WirexStellar", + "Stellar Testnet", + "stellar:CAUGJT4GREIY3WHOUUU5RIUDGSPVREF5CDCYJOWMHOVT2GWQT5JEETGJ", + ), + ), + ), + ) + + private val productionPresets = + listOf( + TokenPreset( + symbol = "USDC", + networks = + listOf( + TokenNetwork("Solana", "Mainnet", "solana:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), + TokenNetwork("Base", "Mainnet", "base:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"), + TokenNetwork( + "Stellar", + "Mainnet", + "stellar:CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + ), + TokenNetwork("Polygon", "Mainnet", "polygon:0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359"), + ), + ), + TokenPreset( + symbol = "EURC", + networks = + listOf( + TokenNetwork("Solana", "Mainnet", "solana:HzwqbKZw8HxMN6bF2yFZNrht3c2iXXzpKcFu7uBEDKtr"), + TokenNetwork("Base", "Mainnet", "base:0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42"), + TokenNetwork( + "Stellar", + "Mainnet", + "stellar:CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV", + ), + ), + ), + ) + + fun presets(environment: Environment): List = + if (environment == Environment.PRODUCTION) productionPresets else stagingPresets + + fun findByLocator( + environment: Environment, + locator: String, + ): Pair? { + for (preset in presets(environment)) { + val network = preset.networks.find { it.locator == locator } + if (network != null) return preset to network + } + return null + } + } +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/AboutSheet.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/AboutSheet.kt new file mode 100644 index 0000000..13a462d --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/AboutSheet.kt @@ -0,0 +1,75 @@ +package com.crossmint.kotlin.checkoutdemo.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.dp +import com.crossmint.kotlin.core.ApiKey +import com.crossmint.kotlin.core.Environment + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AboutSheet( + apiKey: String, + onDismissRequest: () -> Unit, +) { + val environment = runCatching { ApiKey.fromString(apiKey).environment }.getOrNull() + val uriHandler = LocalUriHandler.current + + ModalBottomSheet(onDismissRequest = onDismissRequest) { + Column(modifier = Modifier.padding(bottom = 16.dp)) { + Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Text( + "Environment", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + environment?.value?.replaceFirstChar { it.uppercase() } ?: "Unknown", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = environmentNote(environment), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), + ) + } + + HorizontalDivider() + + ListItem( + headlineContent = { Text("Documentation") }, + trailingContent = { Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) }, + modifier = Modifier.clickable { uriHandler.openUri("https://docs.crossmint.com") }, + ) + ListItem( + headlineContent = { Text("Crossmint Console") }, + trailingContent = { Icon(Icons.AutoMirrored.Filled.OpenInNew, contentDescription = null) }, + modifier = Modifier.clickable { uriHandler.openUri("https://console.crossmint.com") }, + ) + } + } +} + +private fun environmentNote(environment: Environment?): String = + when (environment) { + Environment.STAGING, Environment.DEVELOPMENT -> + "This build points at ${environment.value}. Orders use testnet tokens, so a checkout here cannot move real funds." + Environment.PRODUCTION -> "This build points at production. A checkout here moves real funds." + null -> + "The demo cannot tell which environment this key belongs to. Use a key that starts with ck_staging_ or ck_production_." + } diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/AppTheme.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/AppTheme.kt new file mode 100644 index 0000000..62544b3 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/AppTheme.kt @@ -0,0 +1,61 @@ +package com.crossmint.kotlin.checkoutdemo.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color + +private val CrossmintGreen = Color(0xFF00E6AC) + +private val LightColors = + lightColorScheme( + primary = Color(0xFF00997A), + onPrimary = Color.White, + primaryContainer = Color(0xFFB6FFEA), + onPrimaryContainer = Color(0xFF00201A), + secondary = Color(0xFF00997A), + onSecondary = Color.White, + secondaryContainer = Color(0xFFB6FFEA), + onSecondaryContainer = Color(0xFF00201A), + background = Color.White, + surface = Color.White, + surfaceVariant = Color(0xFFF5F5F5), + onSurface = Color(0xFF1C1C1E), + onSurfaceVariant = Color(0xFF6C6C70), + outlineVariant = Color(0xFFE5E5EA), + ) + +private val DarkColors = + darkColorScheme( + primary = CrossmintGreen, + onPrimary = Color(0xFF00382B), + primaryContainer = Color(0xFF00513E), + onPrimaryContainer = Color(0xFFB6FFEA), + secondary = CrossmintGreen, + onSecondary = Color(0xFF00382B), + secondaryContainer = Color(0xFF00513E), + onSecondaryContainer = Color(0xFFB6FFEA), + background = Color.Black, + surface = Color(0xFF1C1C1E), + surfaceVariant = Color(0xFF2C2C2E), + onSurface = Color(0xFFF2F2F7), + onSurfaceVariant = Color(0xFF8E8E93), + outlineVariant = Color(0xFF38383A), + ) + +@Composable +fun CheckoutDemoTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = if (isSystemInDarkTheme()) DarkColors else LightColors, + content = content, + ) +} + +val ColorScheme.brandMark: Color + @Composable + @ReadOnlyComposable + get() = CrossmintGreen diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/CheckoutDemoApp.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/CheckoutDemoApp.kt new file mode 100644 index 0000000..fad85c0 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/CheckoutDemoApp.kt @@ -0,0 +1,318 @@ +package com.crossmint.kotlin.checkoutdemo.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaffoldNavigator +import androidx.compose.material3.adaptive.navigationsuite.ExperimentalMaterial3AdaptiveNavigationSuiteApi +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.crossmint.crossmintcheckoutdemoapp.generated.resources.Res +import com.crossmint.crossmintcheckoutdemoapp.generated.resources.ic_crossmint +import com.crossmint.kotlin.checkoutdemo.PlaygroundUiState +import com.crossmint.kotlin.checkoutdemo.PlaygroundViewModel +import com.crossmint.kotlin.checkoutdemo.model.PlaygroundSection +import com.crossmint.kotlin.checkoutdemo.ui.sections.AppearanceSectionView +import com.crossmint.kotlin.checkoutdemo.ui.sections.EventsSectionView +import com.crossmint.kotlin.checkoutdemo.ui.sections.FieldsSectionView +import com.crossmint.kotlin.checkoutdemo.ui.sections.IdentitySectionView +import com.crossmint.kotlin.checkoutdemo.ui.sections.OrderSectionView +import com.crossmint.kotlin.checkoutdemo.ui.sections.PaymentSectionView +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.painterResource + +@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun CheckoutDemoApp( + viewModel: PlaygroundViewModel, + apiKey: String, +) { + val uiState by viewModel.uiState.collectAsState() + var showAbout by remember { mutableStateOf(false) } + var showEvents by remember { mutableStateOf(false) } + + val directive = calculatePaneScaffoldDirective(currentWindowAdaptiveInfo()) + if (directive.maxHorizontalPartitions >= 2) { + WideScaffold( + uiState = uiState, + viewModel = viewModel, + apiKey = apiKey, + onShowAbout = { showAbout = true }, + onShowEvents = { showEvents = true }, + ) + } else { + CompactScaffold( + uiState = uiState, + viewModel = viewModel, + apiKey = apiKey, + onShowAbout = { showAbout = true }, + onShowEvents = { showEvents = true }, + ) + } + + if (showAbout) { + AboutSheet(apiKey = apiKey, onDismissRequest = { showAbout = false }) + } + + if (showEvents) { + ModalBottomSheet(onDismissRequest = { showEvents = false }) { + EventsSectionView(events = uiState.events, modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun PlaygroundTopBar( + apiKey: String, + onShowAbout: () -> Unit, + onShowEvents: () -> Unit, +) { + TopAppBar( + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(Res.drawable.ic_crossmint), + contentDescription = null, + tint = MaterialTheme.colorScheme.brandMark, + modifier = Modifier.size(24.dp), + ) + Text("Playground", modifier = Modifier.padding(start = 12.dp)) + } + }, + navigationIcon = { + IconButton(onClick = onShowAbout) { + Icon(Icons.Filled.Info, contentDescription = "About") + } + }, + actions = { + IconButton(onClick = onShowEvents) { + Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Events") + } + EnvironmentBadge(apiKey) + }, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CompactScaffold( + uiState: PlaygroundUiState, + viewModel: PlaygroundViewModel, + apiKey: String, + onShowAbout: () -> Unit, + onShowEvents: () -> Unit, +) { + Scaffold( + topBar = { PlaygroundTopBar(apiKey = apiKey, onShowAbout = onShowAbout, onShowEvents = onShowEvents) }, + bottomBar = { + NavigationBar { + PlaygroundSection.entries.forEach { section -> + NavigationBarItem( + selected = uiState.section == section, + onClick = { viewModel.selectSection(section) }, + icon = { Icon(section.icon, contentDescription = null) }, + label = { Text(section.navLabel) }, + ) + } + } + }, + ) { padding -> + val isPreviewRunning = uiState.isPreviewingCheckout || uiState.isPreviewingIdentity + + if (isPreviewRunning) { + val scaffoldState = rememberBottomSheetScaffoldState() + val scope = rememberCoroutineScope() + + BottomSheetScaffold( + modifier = Modifier.padding(padding).fillMaxSize(), + scaffoldState = scaffoldState, + sheetPeekHeight = 112.dp, + sheetContent = { + PreviewSheetContent( + section = uiState.section, + uiState = uiState, + apiKey = apiKey, + onEvent = viewModel::logExternalEvent, + ) + }, + ) { sheetPadding -> + SectionConfigPane( + section = uiState.section, + uiState = uiState, + viewModel = viewModel, + apiKey = apiKey, + onOpenPreview = { scope.launch { scaffoldState.bottomSheetState.expand() } }, + modifier = Modifier.padding(sheetPadding).fillMaxSize(), + ) + } + } else { + SectionConfigPane( + section = uiState.section, + uiState = uiState, + viewModel = viewModel, + apiKey = apiKey, + onOpenPreview = {}, + modifier = Modifier.padding(padding).fillMaxSize(), + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3AdaptiveNavigationSuiteApi::class) +@Composable +private fun WideScaffold( + uiState: PlaygroundUiState, + viewModel: PlaygroundViewModel, + apiKey: String, + onShowAbout: () -> Unit, + onShowEvents: () -> Unit, +) { + Scaffold( + topBar = { PlaygroundTopBar(apiKey = apiKey, onShowAbout = onShowAbout, onShowEvents = onShowEvents) }, + ) { padding -> + NavigationSuiteScaffold( + modifier = Modifier.padding(padding).fillMaxSize(), + navigationSuiteItems = { + PlaygroundSection.entries.forEach { section -> + item( + selected = uiState.section == section, + onClick = { viewModel.selectSection(section) }, + icon = { Icon(section.icon, contentDescription = null) }, + label = { Text(section.navLabel) }, + ) + } + }, + ) { + WidePlaygroundPanes(uiState = uiState, viewModel = viewModel, apiKey = apiKey) + } + } +} + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +private fun WidePlaygroundPanes( + uiState: PlaygroundUiState, + viewModel: PlaygroundViewModel, + apiKey: String, +) { + val navigator = rememberSupportingPaneScaffoldNavigator() + + PaneBackHandler(navigator) + + SupportingPaneScaffold( + directive = navigator.scaffoldDirective, + value = navigator.scaffoldValue, + mainPane = { + AnimatedPane { + SectionConfigPane( + section = uiState.section, + uiState = uiState, + viewModel = viewModel, + apiKey = apiKey, + onOpenPreview = {}, + modifier = Modifier.fillMaxSize(), + ) + } + }, + supportingPane = { + AnimatedPane { + CheckoutPreviewHost( + section = uiState.section, + uiState = uiState, + apiKey = apiKey, + onEvent = viewModel::logExternalEvent, + modifier = Modifier.fillMaxSize(), + ) + } + }, + ) +} + +@Composable +private fun SectionConfigPane( + section: PlaygroundSection, + uiState: PlaygroundUiState, + viewModel: PlaygroundViewModel, + apiKey: String, + onOpenPreview: () -> Unit, + modifier: Modifier = Modifier, +) { + when (section) { + PlaygroundSection.ORDER -> + OrderSectionView( + uiState = uiState, + apiKey = apiKey, + onDraftChange = viewModel::updateDraft, + onCreateOrder = viewModel::createOrder, + onExistingOrderChange = viewModel::updateExistingOrder, + onUseExistingOrder = viewModel::useExistingOrder, + onOpenPreview = onOpenPreview, + modifier = modifier, + ) + + PlaygroundSection.PAYMENT -> + PaymentSectionView( + options = uiState.options, + onOptionsChange = viewModel::updateOptions, + modifier = modifier, + ) + + PlaygroundSection.APPEARANCE -> + AppearanceSectionView( + options = uiState.options, + onOptionsChange = viewModel::updateOptions, + modifier = modifier, + ) + + PlaygroundSection.FIELDS -> + FieldsSectionView( + options = uiState.options, + onOptionsChange = viewModel::updateOptions, + modifier = modifier, + ) + + PlaygroundSection.IDENTITY -> + IdentitySectionView( + uiState = uiState, + onCredentialsChange = viewModel::updateIdentityCredentials, + onOpenPreview = { + viewModel.startIdentityPreview() + onOpenPreview() + }, + modifier = modifier, + ) + } +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/CheckoutPreviewHost.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/CheckoutPreviewHost.kt new file mode 100644 index 0000000..ea7eead --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/CheckoutPreviewHost.kt @@ -0,0 +1,215 @@ +package com.crossmint.kotlin.checkoutdemo.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.crossmint.crossmintcheckoutdemoapp.generated.resources.Res +import com.crossmint.crossmintcheckoutdemoapp.generated.resources.ic_crossmint +import com.crossmint.kotlin.checkout.CheckoutEnvironment +import com.crossmint.kotlin.checkout.CrossmintEmbeddedCheckout +import com.crossmint.kotlin.checkoutdemo.PlaygroundUiState +import com.crossmint.kotlin.checkoutdemo.data.checkoutEnvironment +import com.crossmint.kotlin.checkoutdemo.model.OrderSession +import com.crossmint.kotlin.checkoutdemo.model.PlaygroundSection +import com.crossmint.kotlin.checkoutdemo.utility.formatTimestamp +import com.crossmint.kotlin.core.ApiKey +import org.jetbrains.compose.resources.painterResource + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CheckoutPreviewHost( + section: PlaygroundSection, + uiState: PlaygroundUiState, + apiKey: String, + onEvent: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var showDetails by remember { mutableStateOf(false) } + val session = uiState.session + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text("Preview") }, + actions = { + if (section != PlaygroundSection.IDENTITY && session != null) { + IconButton(onClick = { showDetails = true }) { + Icon(Icons.Filled.Info, contentDescription = "Order details") + } + } + }, + ) + }, + ) { padding -> + PreviewBody( + section = section, + uiState = uiState, + apiKey = apiKey, + onEvent = onEvent, + modifier = Modifier.padding(padding).fillMaxSize(), + ) + } + + if (showDetails && session != null) { + OrderDetailsSheet(uiState = uiState, session = session, onDismissRequest = { showDetails = false }) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PreviewSheetContent( + section: PlaygroundSection, + uiState: PlaygroundUiState, + apiKey: String, + onEvent: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var showDetails by remember { mutableStateOf(false) } + val session = uiState.session + + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Preview", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + if (section != PlaygroundSection.IDENTITY && session != null) { + IconButton(onClick = { showDetails = true }) { + Icon(Icons.Filled.Info, contentDescription = "Order details") + } + } + } + HorizontalDivider() + PreviewBody( + section = section, + uiState = uiState, + apiKey = apiKey, + onEvent = onEvent, + modifier = Modifier.fillMaxWidth().height(640.dp), + ) + } + + if (showDetails && session != null) { + OrderDetailsSheet(uiState = uiState, session = session, onDismissRequest = { showDetails = false }) + } +} + +@Composable +private fun PreviewBody( + section: PlaygroundSection, + uiState: PlaygroundUiState, + apiKey: String, + onEvent: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val session = uiState.session + + Box(modifier = modifier) { + when { + section == PlaygroundSection.IDENTITY && uiState.isPreviewingIdentity -> + IdentityPreview( + apiKey = apiKey, + inquiryId = uiState.confirmedIdentityInquiryId, + sessionToken = uiState.confirmedIdentitySessionToken, + modifier = Modifier.fillMaxSize(), + onEvent = onEvent, + ) + + section == PlaygroundSection.IDENTITY || session == null -> + EmptyPreviewState(modifier = Modifier.fillMaxSize()) + + else -> { + val environment = + runCatching { ApiKey.fromString(apiKey).checkoutEnvironment } + .getOrDefault(CheckoutEnvironment.STAGING) + + CrossmintEmbeddedCheckout( + orderId = session.orderId, + clientSecret = session.clientSecret, + payment = uiState.options.toCheckoutPayment(uiState.draft.receiptEmail), + appearance = uiState.options.toCheckoutAppearance(), + environment = environment, + identityVerificationHandling = uiState.options.toIdentityVerificationHandling(), + modifier = Modifier.fillMaxSize(), + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun OrderDetailsSheet( + uiState: PlaygroundUiState, + session: OrderSession, + onDismissRequest: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismissRequest) { + Column(modifier = Modifier.padding(bottom = 16.dp)) { + CopyableRow(label = "Order ID", value = session.orderId) + CopyableRow(label = "Client secret", value = session.clientSecret) + + if (uiState.events.isNotEmpty()) { + HorizontalDivider() + Text( + "Events", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(16.dp), + ) + for (event in uiState.events.asReversed()) { + ListItem( + headlineContent = { Text(event.message) }, + overlineContent = { Text(formatTimestamp(event.timestamp)) }, + ) + } + } + } + } +} + +@Composable +private fun EmptyPreviewState(modifier: Modifier = Modifier) { + Box(modifier = modifier.padding(24.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + painter = painterResource(Res.drawable.ic_crossmint), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + modifier = Modifier.size(72.dp), + ) + Text("No order yet", textAlign = TextAlign.Center) + Text( + "Create an order or use an existing one from the Order section.", + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/CopyableRow.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/CopyableRow.kt new file mode 100644 index 0000000..f14162c --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/CopyableRow.kt @@ -0,0 +1,30 @@ +package com.crossmint.kotlin.checkoutdemo.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow + +@Composable +fun CopyableRow( + label: String, + value: String, +) { + val clipboardManager: ClipboardManager = LocalClipboardManager.current + ListItem( + headlineContent = { Text(value, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + overlineContent = { Text(label) }, + trailingContent = { + IconButton(onClick = { clipboardManager.setText(AnnotatedString(value)) }) { + Icon(Icons.Filled.ContentCopy, contentDescription = "Copy $label") + } + }, + ) +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/EnvironmentBadge.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/EnvironmentBadge.kt new file mode 100644 index 0000000..36028c4 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/EnvironmentBadge.kt @@ -0,0 +1,12 @@ +package com.crossmint.kotlin.checkoutdemo.ui + +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import com.crossmint.kotlin.core.ApiKey + +@Composable +fun EnvironmentBadge(apiKey: String) { + val environment = runCatching { ApiKey.fromString(apiKey).environment }.getOrNull() ?: return + AssistChip(onClick = {}, enabled = false, label = { Text(environment.value) }) +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/IdentityPreview.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/IdentityPreview.kt new file mode 100644 index 0000000..9eabb3f --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/IdentityPreview.kt @@ -0,0 +1,13 @@ +package com.crossmint.kotlin.checkoutdemo.ui + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +@Composable +expect fun IdentityPreview( + apiKey: String, + inquiryId: String, + sessionToken: String, + modifier: Modifier, + onEvent: (String) -> Unit, +) diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/PaneBackHandler.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/PaneBackHandler.kt new file mode 100644 index 0000000..abe7031 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/PaneBackHandler.kt @@ -0,0 +1,9 @@ +package com.crossmint.kotlin.checkoutdemo.ui + +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator +import androidx.compose.runtime.Composable + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +expect fun PaneBackHandler(navigator: ThreePaneScaffoldNavigator) diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/SwitchRow.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/SwitchRow.kt new file mode 100644 index 0000000..d4372f0 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/SwitchRow.kt @@ -0,0 +1,18 @@ +package com.crossmint.kotlin.checkoutdemo.ui + +import androidx.compose.material3.ListItem +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable + +@Composable +fun SwitchRow( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + ListItem( + headlineContent = { Text(label) }, + trailingContent = { Switch(checked = checked, onCheckedChange = onCheckedChange) }, + ) +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/AppearanceSectionView.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/AppearanceSectionView.kt new file mode 100644 index 0000000..f0eaa8c --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/AppearanceSectionView.kt @@ -0,0 +1,98 @@ +package com.crossmint.kotlin.checkoutdemo.ui.sections + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.crossmint.kotlin.checkoutdemo.model.PlaygroundOptions + +@Composable +fun AppearanceSectionView( + options: PlaygroundOptions, + onOptionsChange: (PlaygroundOptions) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.verticalScroll(rememberScrollState()).padding(16.dp)) { + Text( + "Colors (hex) are global appearance variables — they recolor every element at " + + "once. Corner radii below are per element.", + style = MaterialTheme.typography.bodyMedium, + ) + + OutlinedTextField( + value = options.textColor, + onValueChange = { onOptionsChange(options.copy(textColor = it)) }, + label = { Text("Text color") }, + placeholder = { Text("#1C1C1E") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) + + OutlinedTextField( + value = options.backgroundColor, + onValueChange = { onOptionsChange(options.copy(backgroundColor = it)) }, + label = { Text("Background color") }, + placeholder = { Text("#FFFFFF") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) + + OutlinedTextField( + value = options.accentColor, + onValueChange = { onOptionsChange(options.copy(accentColor = it)) }, + label = { Text("Accent color") }, + placeholder = { Text("#00C853") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) + + OutlinedTextField( + value = options.dangerColor, + onValueChange = { onOptionsChange(options.copy(dangerColor = it)) }, + label = { Text("Danger color") }, + placeholder = { Text("#D32F2F") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) + + RadiusSlider( + label = "Input corner radius", + value = options.inputRadius, + onValueChange = { onOptionsChange(options.copy(inputRadius = it)) }, + ) + + RadiusSlider( + label = "Tab corner radius", + value = options.tabRadius, + onValueChange = { onOptionsChange(options.copy(tabRadius = it)) }, + ) + + RadiusSlider( + label = "Button corner radius", + value = options.buttonRadius, + onValueChange = { onOptionsChange(options.copy(buttonRadius = it)) }, + ) + } +} + +@Composable +private fun RadiusSlider( + label: String, + value: Float, + onValueChange: (Float) -> Unit, +) { + Text( + "$label: ${value.toInt()}px", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 20.dp), + ) + Slider(value = value, onValueChange = onValueChange, valueRange = 0f..24f) +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/EventsSectionView.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/EventsSectionView.kt new file mode 100644 index 0000000..fafbf70 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/EventsSectionView.kt @@ -0,0 +1,39 @@ +package com.crossmint.kotlin.checkoutdemo.ui.sections + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.crossmint.kotlin.checkoutdemo.model.DemoEvent +import com.crossmint.kotlin.checkoutdemo.utility.formatTimestamp + +@Composable +fun EventsSectionView( + events: List, + modifier: Modifier = Modifier, +) { + if (events.isEmpty()) { + Box(modifier = modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text("No events yet. Create or use an order to get started.") + } + return + } + + LazyColumn(modifier = modifier) { + items(events.asReversed()) { event -> + ListItem( + headlineContent = { Text(event.message) }, + overlineContent = { Text(formatTimestamp(event.timestamp)) }, + ) + HorizontalDivider() + } + } +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/FieldsSectionView.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/FieldsSectionView.kt new file mode 100644 index 0000000..0a8d6a8 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/FieldsSectionView.kt @@ -0,0 +1,39 @@ +package com.crossmint.kotlin.checkoutdemo.ui.sections + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.crossmint.kotlin.checkoutdemo.model.PlaygroundOptions +import com.crossmint.kotlin.checkoutdemo.ui.SwitchRow + +@Composable +fun FieldsSectionView( + options: PlaygroundOptions, + onOptionsChange: (PlaygroundOptions) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.verticalScroll(rememberScrollState())) { + SwitchRow( + label = "Show destination input", + checked = options.showsDestinationInput, + onCheckedChange = { onOptionsChange(options.copy(showsDestinationInput = it)) }, + ) + SwitchRow( + label = "Show receipt email input", + checked = options.showsReceiptEmailInput, + onCheckedChange = { onOptionsChange(options.copy(showsReceiptEmailInput = it)) }, + ) + SwitchRow( + label = "Show status message", + checked = options.showsStatusMessage, + onCheckedChange = { onOptionsChange(options.copy(showsStatusMessage = it)) }, + ) + SwitchRow( + label = "Render identity verification externally", + checked = options.externalIdentityVerification, + onCheckedChange = { onOptionsChange(options.copy(externalIdentityVerification = it)) }, + ) + } +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/IdentitySectionView.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/IdentitySectionView.kt new file mode 100644 index 0000000..90eb92f --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/IdentitySectionView.kt @@ -0,0 +1,59 @@ +package com.crossmint.kotlin.checkoutdemo.ui.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.crossmint.kotlin.checkoutdemo.PlaygroundUiState + +@Composable +fun IdentitySectionView( + uiState: PlaygroundUiState, + onCredentialsChange: (inquiryId: String, sessionToken: String) -> Unit, + onOpenPreview: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + "Paste the inquiry ID (and, if you have one, the session token) from an order's " + + "`payment.preparation.kyc` to preview the KYC step on its own.", + style = MaterialTheme.typography.bodyMedium, + ) + + OutlinedTextField( + value = uiState.identityInquiryId, + onValueChange = { onCredentialsChange(it, uiState.identitySessionToken) }, + label = { Text("Inquiry ID") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.identitySessionToken, + onValueChange = { onCredentialsChange(uiState.identityInquiryId, it) }, + label = { Text("Session token (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + FilledTonalButton( + onClick = onOpenPreview, + enabled = uiState.identityInquiryId.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text("Open preview") + } + } +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/OrderSectionView.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/OrderSectionView.kt new file mode 100644 index 0000000..7922e39 --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/OrderSectionView.kt @@ -0,0 +1,250 @@ +package com.crossmint.kotlin.checkoutdemo.ui.sections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.crossmint.kotlin.checkoutdemo.PlaygroundUiState +import com.crossmint.kotlin.checkoutdemo.model.OrderDraft +import com.crossmint.kotlin.checkoutdemo.model.TokenPreset +import com.crossmint.kotlin.core.ApiKey +import com.crossmint.kotlin.core.Environment + +private enum class OrderMode( + val label: String, +) { + NEW("New order"), + EXISTING("Existing order"), +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OrderSectionView( + uiState: PlaygroundUiState, + apiKey: String, + onDraftChange: (OrderDraft) -> Unit, + onCreateOrder: () -> Unit, + onExistingOrderChange: (orderId: String, clientSecret: String) -> Unit, + onUseExistingOrder: () -> Unit, + onOpenPreview: () -> Unit, + modifier: Modifier = Modifier, +) { + val draft = uiState.draft + var mode by remember { mutableStateOf(OrderMode.NEW) } + + Column( + modifier = modifier.verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + OrderMode.entries.forEachIndexed { index, entry -> + SegmentedButton( + selected = mode == entry, + onClick = { mode = entry }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = OrderMode.entries.size), + ) { + Text(entry.label) + } + } + } + + if (mode == OrderMode.NEW) { + TokenPicker( + apiKey = apiKey, + tokenLocator = draft.tokenLocator, + onTokenLocatorChange = { onDraftChange(draft.copy(tokenLocator = it)) }, + ) + + OutlinedTextField( + value = draft.amount, + onValueChange = { onDraftChange(draft.copy(amount = it)) }, + label = { Text("Amount") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = draft.recipientWalletAddress, + onValueChange = { onDraftChange(draft.copy(recipientWalletAddress = it)) }, + label = { Text("Recipient wallet address") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = draft.receiptEmail, + onValueChange = { onDraftChange(draft.copy(receiptEmail = it)) }, + label = { Text("Receipt email") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + uiState.orderErrorMessage?.let { message -> + Text(message, color = MaterialTheme.colorScheme.error) + } + + Button( + onClick = onCreateOrder, + enabled = + !uiState.isCreatingOrder && + draft.tokenLocator.isNotBlank() && + draft.amount.isNotBlank() && + draft.recipientWalletAddress.isNotBlank() && + draft.receiptEmail.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + if (uiState.isCreatingOrder) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text("Create order") + } + } + } else { + OutlinedTextField( + value = uiState.existingOrderId, + onValueChange = { onExistingOrderChange(it, uiState.existingClientSecret) }, + label = { Text("Order ID") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedTextField( + value = uiState.existingClientSecret, + onValueChange = { onExistingOrderChange(uiState.existingOrderId, it) }, + label = { Text("Client secret") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + OutlinedButton( + onClick = onUseExistingOrder, + enabled = uiState.existingOrderId.isNotBlank() && uiState.existingClientSecret.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text("Use this order") + } + } + + if (uiState.isPreviewingCheckout) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + FilledTonalButton(onClick = onOpenPreview, modifier = Modifier.fillMaxWidth()) { + Text("Open preview") + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun TokenPicker( + apiKey: String, + tokenLocator: String, + onTokenLocatorChange: (String) -> Unit, +) { + val environment = + remember(apiKey) { runCatching { ApiKey.fromString(apiKey).environment }.getOrDefault(Environment.STAGING) } + val presets = remember(environment) { TokenPreset.presets(environment) } + val match = remember(presets, tokenLocator) { TokenPreset.findByLocator(environment, tokenLocator) } + val selectedPreset = match?.first + var isCustom by remember(presets) { mutableStateOf(tokenLocator.isNotBlank() && match == null) } + + var symbolExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = symbolExpanded, onExpandedChange = { symbolExpanded = it }) { + OutlinedTextField( + value = + when { + selectedPreset != null -> selectedPreset.symbol + isCustom -> "Custom token" + else -> "" + }, + onValueChange = {}, + readOnly = true, + label = { Text("Token") }, + placeholder = { Text("Select a token") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = symbolExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu(expanded = symbolExpanded, onDismissRequest = { symbolExpanded = false }) { + for (preset in presets) { + DropdownMenuItem( + text = { Text(preset.symbol) }, + onClick = { + isCustom = false + onTokenLocatorChange(preset.networks.first().locator) + symbolExpanded = false + }, + ) + } + DropdownMenuItem( + text = { Text("Custom token") }, + onClick = { + isCustom = true + onTokenLocatorChange("") + symbolExpanded = false + }, + ) + } + } + + if (selectedPreset != null) { + var networkExpanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox(expanded = networkExpanded, onExpandedChange = { networkExpanded = it }) { + OutlinedTextField( + value = match.second.title, + onValueChange = {}, + readOnly = true, + label = { Text("Chain") }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = networkExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable), + ) + ExposedDropdownMenu(expanded = networkExpanded, onDismissRequest = { networkExpanded = false }) { + for (network in selectedPreset.networks) { + DropdownMenuItem( + text = { Text(network.title) }, + onClick = { + onTokenLocatorChange(network.locator) + networkExpanded = false + }, + ) + } + } + } + } + + if (isCustom) { + OutlinedTextField( + value = tokenLocator, + onValueChange = onTokenLocatorChange, + label = { Text("Token locator") }, + placeholder = { Text("chain:token-address") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/PaymentSectionView.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/PaymentSectionView.kt new file mode 100644 index 0000000..b4fe1fe --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/ui/sections/PaymentSectionView.kt @@ -0,0 +1,39 @@ +package com.crossmint.kotlin.checkoutdemo.ui.sections + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.crossmint.kotlin.checkoutdemo.model.PlaygroundOptions +import com.crossmint.kotlin.checkoutdemo.ui.SwitchRow + +@Composable +fun PaymentSectionView( + options: PlaygroundOptions, + onOptionsChange: (PlaygroundOptions) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.verticalScroll(rememberScrollState())) { + SwitchRow( + label = "Fiat payment", + checked = options.fiatEnabled, + onCheckedChange = { onOptionsChange(options.copy(fiatEnabled = it)) }, + ) + SwitchRow( + label = "Allow card", + checked = options.allowCard, + onCheckedChange = { onOptionsChange(options.copy(allowCard = it)) }, + ) + SwitchRow( + label = "Allow Google Pay", + checked = options.allowGooglePay, + onCheckedChange = { onOptionsChange(options.copy(allowGooglePay = it)) }, + ) + SwitchRow( + label = "Crypto payment", + checked = options.cryptoEnabled, + onCheckedChange = { onOptionsChange(options.copy(cryptoEnabled = it)) }, + ) + } +} diff --git a/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/utility/Time.kt b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/utility/Time.kt new file mode 100644 index 0000000..c723c2c --- /dev/null +++ b/checkout/src/commonMain/kotlin/com/crossmint/kotlin/checkoutdemo/utility/Time.kt @@ -0,0 +1,5 @@ +package com.crossmint.kotlin.checkoutdemo.utility + +expect fun currentTimeMillis(): Long + +expect fun formatTimestamp(timestamp: Long): String diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f8908a..c45a721 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -crossmint = "1.1.0" +crossmint = "1.2.0" agp = "8.12.2" android-compileSdk = "35" @@ -16,6 +16,8 @@ kotlinx-serialization = "1.9.0" ktor = "3.3.1" navigationCompose = "2.9.1" processPhoenix = "3.0.0" +material3Adaptive = "1.2.0" +material3AdaptiveNavigationSuite = "1.9.0" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } @@ -34,6 +36,12 @@ process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "p crossmint-sdk = { module = "com.crossmint:crossmint-sdk", version.ref = "crossmint" } crossmint-compose = { module = "com.crossmint:crossmint-compose", version.ref = "crossmint" } crossmint-checkout = { module = "com.crossmint:sdk-checkout", version.ref = "crossmint" } +crossmint-core = { module = "com.crossmint:sdk-core", version.ref = "crossmint" } +crossmint-identity = { module = "com.crossmint:sdk-identity", version.ref = "crossmint" } +material3-adaptive = { module = "org.jetbrains.compose.material3.adaptive:adaptive", version.ref = "material3Adaptive" } +material3-adaptive-layout = { module = "org.jetbrains.compose.material3.adaptive:adaptive-layout", version.ref = "material3Adaptive" } +material3-adaptive-navigation = { module = "org.jetbrains.compose.material3.adaptive:adaptive-navigation", version.ref = "material3Adaptive" } +material3-adaptive-navigation-suite = { module = "org.jetbrains.compose.material3:material3-adaptive-navigation-suite", version.ref = "material3AdaptiveNavigationSuite" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index b676d9a..fa76560 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,3 +26,4 @@ dependencyResolutionManagement { rootProject.name = "Crossmint Quickstart" include(":app") +include(":checkout")