diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/AndroidManifest.xml b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/AndroidManifest.xml index aae0dd93..dc4e1a22 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/AndroidManifest.xml +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/AndroidManifest.xml @@ -54,6 +54,7 @@ android:name=".MainActivity" android:configChanges="orientation|keyboardHidden" android:exported="true" + android:launchMode="singleTask" android:screenOrientation="portrait" android:windowSoftInputMode="adjustResize" tools:ignore="LockedOrientationActivity"> @@ -62,6 +63,17 @@ + + + + + + + + + diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/CheckoutKitApp.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/CheckoutKitApp.kt index 3a3b5b32..28e3b128 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/CheckoutKitApp.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/CheckoutKitApp.kt @@ -1,5 +1,6 @@ package com.shopify.checkout_kit_android_demo +import android.net.Uri import androidx.compose.foundation.Image import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Column @@ -32,32 +33,52 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.unit.dp import androidx.navigation.compose.rememberNavController +import com.shopify.checkout_kit_android_demo.cart.CartBootstrapException +import com.shopify.checkout_kit_android_demo.cart.CartBootstrapLink import com.shopify.checkout_kit_android_demo.cart.CartViewModel import com.shopify.checkout_kit_android_demo.cart.data.totalQuantity +import com.shopify.checkout_kit_android_demo.common.ID import com.shopify.checkout_kit_android_demo.common.ObserveAsEvents import com.shopify.checkout_kit_android_demo.common.SnackbarController +import com.shopify.checkout_kit_android_demo.common.SnackbarEvent import com.shopify.checkout_kit_android_demo.common.navigation.BottomAppBarWithNavigation import com.shopify.checkout_kit_android_demo.common.navigation.CheckoutKitNavHost import com.shopify.checkout_kit_android_demo.common.navigation.Screen import com.shopify.checkout_kit_android_demo.common.ui.theme.CheckoutKitSampleTheme import com.shopify.checkout_kit_android_demo.logs.LogsViewModel +import com.shopify.checkout_kit_android_demo.products.product.data.ProductRepository import com.shopify.checkout_kit_android_demo.settings.SettingsUiState import com.shopify.checkout_kit_android_demo.settings.SettingsViewModel import com.shopify.checkoutkit.ColorScheme import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel +import org.koin.compose.koinInject @Composable -fun CheckoutKitApp() { +fun CheckoutKitApp( + cartBootstrapUri: Uri? = null, + onCartBootstrapHandled: () -> Unit = {}, +) { val settingsViewModel = koinViewModel() val cartViewModel = koinViewModel() val logsViewModel = koinViewModel() + val productRepository = koinInject() - CheckoutKitAppRoot(settingsViewModel, cartViewModel, logsViewModel) + CheckoutKitAppRoot( + settingsViewModel = settingsViewModel, + cartViewModel = cartViewModel, + logsViewModel = logsViewModel, + productRepository = productRepository, + cartBootstrapUri = cartBootstrapUri, + onCartBootstrapHandled = onCartBootstrapHandled, + ) } @OptIn(ExperimentalMaterial3Api::class) @@ -66,6 +87,9 @@ fun CheckoutKitAppRoot( settingsViewModel: SettingsViewModel, cartViewModel: CartViewModel, logsViewModel: LogsViewModel, + productRepository: ProductRepository, + cartBootstrapUri: Uri?, + onCartBootstrapHandled: () -> Unit, ) { val useDarkTheme = settingsViewModel.uiState.collectAsState().value .isDarkTheme(isSystemInDarkTheme()) @@ -76,7 +100,12 @@ fun CheckoutKitAppRoot( CheckoutKitSampleTheme(darkTheme = useDarkTheme) { Surface( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .testTag("checkout-kit-sample-ready") + .semantics { + testTagsAsResourceId = true + }, ) { val navController = rememberNavController() var currentScreen by remember { mutableStateOf(Screen.Product) } @@ -98,6 +127,25 @@ fun CheckoutKitAppRoot( } } + LaunchedEffect(cartBootstrapUri) { + val uri = cartBootstrapUri ?: return@LaunchedEffect + + try { + val cartBootstrapLink = CartBootstrapLink.parse(uri) ?: return@LaunchedEffect + val variantId = variantIdFor(cartBootstrapLink, productRepository) + + cartViewModel.seedCart(variantId, cartBootstrapLink.quantity) { result -> + result.onSuccess { + navController.navigate(Screen.Cart.route) + } + } + } catch (e: Exception) { + SnackbarController.sendEvent(SnackbarEvent(R.string.cart_bootstrap_failed)) + } finally { + onCartBootstrapHandled() + } + } + Scaffold( snackbarHost = { SnackbarHost(hostState = snackbarHostState) @@ -117,9 +165,12 @@ fun CheckoutKitAppRoot( ) }, actions = { - IconButton(onClick = { - navController.navigate(Screen.Cart.route) - }) { + IconButton( + modifier = Modifier.testTag("cart-tab"), + onClick = { + navController.navigate(Screen.Cart.route) + } + ) { BadgedBox(badge = { if (totalQuantity > 0) { Badge( @@ -164,6 +215,24 @@ fun CheckoutKitAppRoot( } } +private suspend fun variantIdFor( + cartBootstrapLink: CartBootstrapLink, + productRepository: ProductRepository, +): ID { + cartBootstrapLink.variantId?.let { return it } + + val productIndex = cartBootstrapLink.productIndex + ?: throw CartBootstrapException("Missing variantId or productIndex") + val product = productRepository + .getProducts(numProducts = productIndex + 1, numVariants = 1, cursor = null) + .products + .getOrNull(productIndex) + val variant = product?.variants?.firstOrNull() + ?: throw CartBootstrapException("Cart bootstrap product variant was not found") + + return variant.id +} + data class AppBarState( val actions: @Composable RowScope.() -> Unit = {}, ) diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/MainActivity.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/MainActivity.kt index 74f39805..f0cf9e5a 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/MainActivity.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/MainActivity.kt @@ -1,6 +1,7 @@ package com.shopify.checkout_kit_android_demo import android.Manifest +import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.os.Bundle @@ -13,6 +14,9 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.core.content.ContextCompat import timber.log.Timber import timber.log.Timber.DebugTree @@ -27,10 +31,13 @@ class MainActivity : ComponentActivity() { private var geolocationPermissionCallback: GeolocationPermissions.Callback? = null private var geolocationOrigin: String? = null + private var cartBootstrapUri by mutableStateOf(null) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + cartBootstrapUri = intent?.data + enableEdgeToEdge() // Allow debugging the WebView via chrome://inspect @@ -42,7 +49,10 @@ class MainActivity : ComponentActivity() { } setContent { - CheckoutKitApp() + CheckoutKitApp( + cartBootstrapUri = cartBootstrapUri, + onCartBootstrapHandled = { cartBootstrapUri = null }, + ) } requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> @@ -67,6 +77,12 @@ class MainActivity : ComponentActivity() { } } + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + cartBootstrapUri = intent.data + } + fun onShowFileChooser(filePathCallback: ValueCallback>, fileChooserParams: FileChooserParams): Boolean { this.filePathCallback = filePathCallback if (permissionAlreadyGranted(Manifest.permission.CAMERA)) { diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartBootstrapLink.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartBootstrapLink.kt new file mode 100644 index 00000000..757295ce --- /dev/null +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartBootstrapLink.kt @@ -0,0 +1,53 @@ +package com.shopify.checkout_kit_android_demo.cart + +import android.net.Uri +import com.shopify.checkout_kit_android_demo.common.ID + +data class CartBootstrapLink( + val variantId: ID?, + val productIndex: Int?, + val quantity: Int, +) { + companion object { + private const val SCHEME = "checkout-kit-android" + private const val HOST = "cart" + + fun parse(uri: Uri): CartBootstrapLink? { + if (uri.scheme != SCHEME) return null + + if (uri.host != HOST) { + throw CartBootstrapException("Unsupported cart bootstrap path") + } + + val variantId = uri.getQueryParameter("variantId")?.trim() + val productIndexParam = uri.getQueryParameter("productIndex")?.trim() + val quantityParam = uri.getQueryParameter("quantity")?.trim() ?: "1" + val quantity = quantityParam.toIntOrNull() + + if (quantity == null || quantity < 1) { + throw CartBootstrapException("quantity must be a positive integer") + } + + if (!variantId.isNullOrEmpty() && !productIndexParam.isNullOrEmpty()) { + throw CartBootstrapException("Use variantId or productIndex, not both") + } + + if (!variantId.isNullOrEmpty()) { + return CartBootstrapLink(variantId = ID(variantId), productIndex = null, quantity = quantity) + } + + if (productIndexParam.isNullOrEmpty()) { + throw CartBootstrapException("Missing variantId or productIndex") + } + + val productIndex = productIndexParam.toIntOrNull() + if (productIndex == null || productIndex < 0) { + throw CartBootstrapException("productIndex must be a non-negative integer") + } + + return CartBootstrapLink(variantId = null, productIndex = productIndex, quantity = quantity) + } + } +} + +class CartBootstrapException(message: String) : IllegalArgumentException(message) diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartView.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartView.kt index 5e2b23a1..6326dca4 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartView.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartView.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -231,6 +232,7 @@ private fun CheckoutButton( modifier = modifier ) { Button( + modifier = Modifier.testTag("checkout-button"), shape = RectangleShape, onClick = onClick, ) { @@ -254,7 +256,7 @@ private fun EmptyCartMessage( modifier: Modifier, ) { Box( - modifier, + modifier.testTag("cart-empty-message"), contentAlignment = Alignment.Center, ) { Column( diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartViewModel.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartViewModel.kt index 0482de47..8f5f385c 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartViewModel.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/cart/CartViewModel.kt @@ -72,6 +72,11 @@ class CartViewModel( } } + fun seedCart(variantId: ID, quantity: Int, onComplete: OnComplete) { + clearCart() + addToCart(variantId, quantity, onComplete) + } + fun modifyLineItem(lineItemId: ID, quantity: Int?) = viewModelScope.launch { when (val state = _cartState.value) { is CartState.Cart -> { diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/common/navigation/BottomAppBarWithNavigation.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/common/navigation/BottomAppBarWithNavigation.kt index f1b3d2f6..231ca550 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/common/navigation/BottomAppBarWithNavigation.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/common/navigation/BottomAppBarWithNavigation.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.semantics.contentDescription @@ -40,6 +41,7 @@ fun BottomAppBarWithNavigation( ImageVector.vectorResource(R.drawable.home), stringResource(id = R.string.navigation_home), currentScreen, + "home-tab", ) NavigationItem( navController, @@ -47,6 +49,7 @@ fun BottomAppBarWithNavigation( ImageVector.vectorResource(R.drawable.product), stringResource(id = R.string.navigation_shop), currentScreen, + "products-tab", ) NavigationItem( navController, @@ -54,6 +57,7 @@ fun BottomAppBarWithNavigation( ImageVector.vectorResource(R.drawable.profile), stringResource(id = R.string.navigation_log_in), currentScreen, + "settings-tab", ) } } @@ -66,6 +70,7 @@ fun NavigationItem( icon: ImageVector, label: String, currentScreen: Screen, + testTag: String, ) { val isActiveScreen = currentScreen == screen val color = if (isActiveScreen) { @@ -77,9 +82,11 @@ fun NavigationItem( Column { IconButton( onClick = { navController.navigate(screen.route) }, - modifier = Modifier.semantics { - this.contentDescription = "$label icon" - } + modifier = Modifier + .testTag(testTag) + .semantics { + this.contentDescription = "$label icon" + } ) { Icon(imageVector = icon, contentDescription = label, tint = color) } diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/products/ProductsView.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/products/ProductsView.kt index ddae6edb..ff9df50a 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/products/ProductsView.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/products/ProductsView.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -97,6 +98,7 @@ fun ProductsView( Product( product = product, imageHeight = if (largeScreen) defaultProductImageHeightLg else defaultProductImageHeight, + testTag = "product-${index}-grid-item", onProductClick = { productId -> productsViewModel.productClicked(navController, productId) } @@ -113,10 +115,12 @@ fun ProductsView( fun Product( product: Product, imageHeight: Dp, + testTag: String, onProductClick: (id: ID) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier .wrapContentWidth() + .testTag(testTag) .clickable { onProductClick(product.id) }) { diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/products/product/AddToCartButton.kt b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/products/product/AddToCartButton.kt index a8cf783c..a487ef70 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/products/product/AddToCartButton.kt +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/java/com/shopify/checkout_kit_android_demo/products/product/AddToCartButton.kt @@ -10,6 +10,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -28,7 +29,9 @@ fun AddToCartButton( horizontalAlignment = Alignment.CenterHorizontally, ) { TextButton( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag("add-to-cart-button"), enabled = !loading, onClick = onClick, border = BorderStroke(1.dp, MaterialTheme.colorScheme.onBackground), diff --git a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/res/values/strings.xml b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/res/values/strings.xml index f62c1c74..984da372 100644 --- a/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/res/values/strings.xml +++ b/platforms/android/samples/CheckoutKitAndroidDemo/app/src/main/res/values/strings.xml @@ -46,6 +46,7 @@ Failed to load products Failed to create cart Failed to update cart + Failed to prepare cart An error occurred when completing log in Failed to load customerR