Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand All @@ -62,6 +63,17 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:host="cart"
android:scheme="checkout-kit-android" />
</intent-filter>

<meta-data
android:name="android.app.lib_name"
android:value="" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<SettingsViewModel>()
val cartViewModel = koinViewModel<CartViewModel>()
val logsViewModel = koinViewModel<LogsViewModel>()
val productRepository = koinInject<ProductRepository>()

CheckoutKitAppRoot(settingsViewModel, cartViewModel, logsViewModel)
CheckoutKitAppRoot(
settingsViewModel = settingsViewModel,
cartViewModel = cartViewModel,
logsViewModel = logsViewModel,
productRepository = productRepository,
cartBootstrapUri = cartBootstrapUri,
onCartBootstrapHandled = onCartBootstrapHandled,
)
}

@OptIn(ExperimentalMaterial3Api::class)
Expand All @@ -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())
Expand All @@ -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>(Screen.Product) }
Expand All @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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 = {},
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -27,10 +31,13 @@ class MainActivity : ComponentActivity() {

private var geolocationPermissionCallback: GeolocationPermissions.Callback? = null
private var geolocationOrigin: String? = null
private var cartBootstrapUri by mutableStateOf<Uri?>(null)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

cartBootstrapUri = intent?.data

enableEdgeToEdge()

// Allow debugging the WebView via chrome://inspect
Expand All @@ -42,7 +49,10 @@ class MainActivity : ComponentActivity() {
}

setContent {
CheckoutKitApp()
CheckoutKitApp(
cartBootstrapUri = cartBootstrapUri,
onCartBootstrapHandled = { cartBootstrapUri = null },
)
}

requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
Expand All @@ -67,6 +77,12 @@ class MainActivity : ComponentActivity() {
}
}

override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
cartBootstrapUri = intent.data
}

fun onShowFileChooser(filePathCallback: ValueCallback<Array<Uri>>, fileChooserParams: FileChooserParams): Boolean {
this.filePathCallback = filePathCallback
if (permissionAlreadyGranted(Manifest.permission.CAMERA)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -231,6 +232,7 @@ private fun CheckoutButton(
modifier = modifier
) {
Button(
modifier = Modifier.testTag("checkout-button"),
shape = RectangleShape,
onClick = onClick,
) {
Expand All @@ -254,7 +256,7 @@ private fun EmptyCartMessage(
modifier: Modifier,
) {
Box(
modifier,
modifier.testTag("cart-empty-message"),
contentAlignment = Alignment.Center,
) {
Column(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,20 +41,23 @@ fun BottomAppBarWithNavigation(
ImageVector.vectorResource(R.drawable.home),
stringResource(id = R.string.navigation_home),
currentScreen,
"home-tab",
)
NavigationItem(
navController,
Screen.Products,
ImageVector.vectorResource(R.drawable.product),
stringResource(id = R.string.navigation_shop),
currentScreen,
"products-tab",
)
NavigationItem(
navController,
Screen.Settings,
ImageVector.vectorResource(R.drawable.profile),
stringResource(id = R.string.navigation_log_in),
currentScreen,
"settings-tab",
)
}
}
Expand All @@ -66,6 +70,7 @@ fun NavigationItem(
icon: ImageVector,
label: String,
currentScreen: Screen,
testTag: String,
) {
val isActiveScreen = currentScreen == screen
val color = if (isActiveScreen) {
Expand All @@ -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)
}
Expand Down
Loading
Loading