Skip to content

Commit 769d5be

Browse files
authored
Add cart quantity controls and expose preload cache invalidate function (#140)
1 parent 12ecb9f commit 769d5be

File tree

11 files changed

+138
-10
lines changed

11 files changed

+138
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 3.1.2 October 15 2024
44

55
- Prevent entering recovery mode for single-use multipass URLs.
6+
- Add invalidate() function to interface to allow invalidating preloaded checkouts when necessary.
67

78
## 3.1.1 October 2, 2024
89

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,12 @@ ShopifyCheckoutSheetKit.configure {
194194
ShopifyCheckoutSheetKit.preload(checkoutUrl) // no-op
195195
```
196196
197+
#### Invalidation
198+
199+
To invalidate a preloaded checkout, call `ShopifyCheckoutSheetKit.invalidate()`. This function will be a no-op if no checkout is preloaded.
200+
201+
You may wish to do this if the buyer changes shortly before entering checkout, e.g. by changing cart quantity on a cart view.
202+
197203
#### Lifecycle management for preloaded checkout
198204
199205
Preloading renders a checkout in a background webview, which is brought to foreground when `ShopifyCheckoutSheetKit.present()` is called. The content of preloaded checkout reflects the state of the cart when `preload()` was initially called. If the cart is mutated after `preload()` is called, the application is responsible for invalidating the preloaded checkout to ensure that up-to-date checkout content is displayed to the buyer:

lib/src/main/java/com/shopify/checkoutsheetkit/ShopifyCheckoutSheetKit.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ public object ShopifyCheckoutSheetKit {
6868
CheckoutWebView.clearCache()
6969
}
7070

71+
/**
72+
* Invalidate WebViews cached due to preload calls
73+
*/
74+
@JvmStatic
75+
public fun invalidate() {
76+
CheckoutWebView.markCacheEntryStale()
77+
}
78+
7179
/**
7280
* Preloads a Shopify checkout in the background.
7381
*

lib/src/test/java/com/shopify/checkoutsheetkit/CheckoutWebViewContainerTest.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ import org.mockito.kotlin.whenever
3434
import org.robolectric.Robolectric
3535
import org.robolectric.RobolectricTestRunner
3636
import org.robolectric.Shadows.shadowOf
37-
import kotlin.time.Duration.Companion.minutes
3837

3938
@RunWith(RobolectricTestRunner::class)
4039
class CheckoutWebViewContainerTest {

lib/src/test/java/com/shopify/checkoutsheetkit/ShopifyCheckoutSheetKitTest.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,25 @@ class ShopifyCheckoutSheetKitTest {
7676
}
7777
}
7878

79+
@Test
80+
fun `invalidate marks cache entry as stale meaning it will not be used when presenting`() {
81+
Robolectric.buildActivity(ComponentActivity::class.java).use { activityController ->
82+
withPreloadingEnabled {
83+
val url = "https://shopify.dev"
84+
ShopifyCheckoutSheetKit.preload(url, activityController.get())
85+
ShadowLooper.shadowMainLooper().runToEndOfTasks()
86+
87+
ShopifyCheckoutSheetKit.invalidate()
88+
ShadowLooper.shadowMainLooper().runToEndOfTasks()
89+
90+
assertThat(CheckoutWebView.cacheEntry).isNotNull()
91+
val entry = CheckoutWebView.cacheEntry!!
92+
93+
assertThat(entry.isStale).isTrue()
94+
}
95+
}
96+
}
97+
7998
@Test
8099
fun `preload caches a new WebView, loads the URL, and destroys old view if cache is not empty`() {
81100
Robolectric.buildActivity(ComponentActivity::class.java).use { activityController ->

samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/CartItem.kt

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,26 @@ import androidx.compose.foundation.layout.Column
2727
import androidx.compose.foundation.layout.Row
2828
import androidx.compose.foundation.layout.fillMaxWidth
2929
import androidx.compose.foundation.layout.padding
30+
import androidx.compose.foundation.layout.width
3031
import androidx.compose.material.Card
3132
import androidx.compose.material.Text
33+
import androidx.compose.material.TextButton
3234
import androidx.compose.runtime.Composable
35+
import androidx.compose.ui.Alignment
3336
import androidx.compose.ui.Modifier
37+
import androidx.compose.ui.text.style.TextAlign
3438
import androidx.compose.ui.unit.dp
3539
import androidx.compose.ui.unit.sp
3640

3741
@Composable
38-
fun CartItem(title: String, vendor: String, quantity: Int, modifier: Modifier) {
42+
fun CartItem(
43+
loading: Boolean,
44+
title: String,
45+
vendor: String,
46+
quantity: Int,
47+
setQuantity: (Int) -> Unit,
48+
modifier: Modifier
49+
) {
3950
Card(
4051
elevation = 0.dp,
4152
modifier = modifier
@@ -44,12 +55,31 @@ fun CartItem(title: String, vendor: String, quantity: Int, modifier: Modifier) {
4455
horizontalArrangement = Arrangement.SpaceBetween,
4556
modifier = Modifier.fillMaxWidth(.9f).padding(10.dp),
4657
) {
47-
Column(Modifier.weight(.9f)) {
58+
Column(Modifier.weight(.9f).align(Alignment.CenterVertically)) {
4859
Text(title)
4960
Text(vendor, fontSize = 10.sp)
5061
}
5162

52-
Text("$quantity")
63+
Row {
64+
TextButton(
65+
modifier = Modifier.width(40.dp),
66+
enabled = !loading,
67+
onClick = { setQuantity(quantity - 1) }) {
68+
Text("-")
69+
}
70+
Text(
71+
text = "$quantity",
72+
textAlign = TextAlign.Center,
73+
modifier = Modifier.align(Alignment.CenterVertically)
74+
)
75+
TextButton(
76+
modifier = Modifier.width(40.dp),
77+
enabled = !loading,
78+
onClick = { setQuantity(quantity + 1) }
79+
) {
80+
Text("+")
81+
}
82+
}
5383
}
5484
}
5585
}

samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/CartState.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ sealed class CartState {
3838

3939
}
4040

41-
data class CartLine(val title: String, val vendor: String, val quantity: Int)
41+
data class CartLine(val id: ID, val title: String, val vendor: String, val quantity: Int)
4242
data class CartTotals(val totalQuantity: Int, val totalAmount: Amount)
4343
data class Amount(val currency: String, val price: Double)
4444

samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/CartView.kt

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,18 @@ import androidx.compose.foundation.lazy.items
3636
import androidx.compose.material.Button
3737
import androidx.compose.material.Icon
3838
import androidx.compose.material.IconButton
39+
import androidx.compose.material.LinearProgressIndicator
3940
import androidx.compose.material.MaterialTheme
4041
import androidx.compose.material.Text
4142
import androidx.compose.material.icons.Icons
4243
import androidx.compose.material.icons.filled.Delete
4344
import androidx.compose.runtime.Composable
4445
import androidx.compose.runtime.LaunchedEffect
4546
import androidx.compose.runtime.collectAsState
47+
import androidx.compose.runtime.getValue
48+
import androidx.compose.runtime.mutableStateOf
49+
import androidx.compose.runtime.remember
50+
import androidx.compose.runtime.setValue
4651
import androidx.compose.ui.Alignment
4752
import androidx.compose.ui.Modifier
4853
import androidx.compose.ui.platform.LocalContext
@@ -54,6 +59,7 @@ import com.shopify.checkout_sdk_mobile_buy_integration_sample.AppBarState
5459
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.toDisplayText
5560
import com.shopify.checkoutsheetkit.DefaultCheckoutEventProcessor
5661
import com.shopify.checkoutsheetkit.ShopifyCheckoutSheetKit
62+
import com.shopify.graphql.support.ID
5763

5864
@Composable
5965
fun <T: DefaultCheckoutEventProcessor> CartView(
@@ -62,7 +68,10 @@ fun <T: DefaultCheckoutEventProcessor> CartView(
6268
checkoutEventProcessor: T,
6369
) {
6470
val state = cartViewModel.cartState.collectAsState().value
71+
val loading = cartViewModel.loadingState.collectAsState().value
72+
6573
val activity = LocalContext.current as ComponentActivity
74+
var mutableQuantity by remember { mutableStateOf<Map<String, Int>>(mutableMapOf()) }
6675

6776
LaunchedEffect(state) {
6877
setAppBarState(
@@ -86,21 +95,32 @@ fun <T: DefaultCheckoutEventProcessor> CartView(
8695
}
8796
}
8897

98+
if (loading) {
99+
LinearProgressIndicator(
100+
modifier = Modifier.fillMaxWidth(),
101+
)
102+
}
103+
89104
Column(
90105
Modifier
91106
.fillMaxSize()
92107
.padding(20.dp),
93108
verticalArrangement = Arrangement.SpaceBetween
94109
) {
95-
val activity = LocalContext.current as ComponentActivity
96110
when (state) {
97111
is CartState.Empty -> {
98112
EmptyCartMessage(Modifier.fillMaxSize())
99113
}
100114

101115
is CartState.Populated -> {
116+
mutableQuantity = state.cartLines.associate {
117+
it.title to it.quantity
118+
}
119+
102120
CartLines(
103121
lines = state.cartLines,
122+
loading = loading,
123+
setQuantity = cartViewModel::updateCartQuantity,
104124
modifier = Modifier.weight(1f, false)
105125
)
106126
CheckoutButton(
@@ -120,7 +140,7 @@ fun <T: DefaultCheckoutEventProcessor> CartView(
120140
}
121141

122142
@Composable
123-
private fun CartLines(lines: List<CartLine>, modifier: Modifier = Modifier) {
143+
private fun CartLines(lines: List<CartLine>, loading: Boolean, setQuantity: (ID, Int) -> Unit, modifier: Modifier = Modifier) {
124144
LazyColumn(modifier) {
125145
items(lines) { item ->
126146
Row(
@@ -133,6 +153,8 @@ private fun CartLines(lines: List<CartLine>, modifier: Modifier = Modifier) {
133153
title = item.title,
134154
vendor = item.vendor,
135155
quantity = item.quantity,
156+
setQuantity = { quantity -> setQuantity(item.id, quantity) },
157+
loading = loading,
136158
modifier = Modifier
137159
.fillMaxWidth(.9f)
138160
.align(alignment = Alignment.CenterVertically)

samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/cart/CartViewModel.kt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
*/
2323
package com.shopify.checkout_sdk_mobile_buy_integration_sample.cart
2424

25+
import android.util.Log
2526
import androidx.activity.ComponentActivity
2627
import androidx.lifecycle.ViewModel
2728
import androidx.lifecycle.viewModelScope
@@ -47,6 +48,9 @@ class CartViewModel(
4748
private val _cartState = MutableStateFlow<CartState>(CartState.Empty)
4849
val cartState: StateFlow<CartState> = _cartState.asStateFlow()
4950

51+
private val _loadingState = MutableStateFlow(false)
52+
val loadingState: StateFlow<Boolean> = _loadingState
53+
5054
private var demoBuyerIdentityEnabled = false
5155

5256
init {
@@ -68,6 +72,21 @@ class CartViewModel(
6872
}
6973
}
7074

75+
fun updateCartQuantity(lineItemID: ID, quantity: Int) {
76+
when (val state = _cartState.value) {
77+
is CartState.Populated -> {
78+
_loadingState.value = true
79+
client.cartLinesUpdate(state.cartID, lineItemID, quantity, {
80+
// Invalidate any preload calls so checkout reflects latest quantity
81+
ShopifyCheckoutSheetKit.invalidate()
82+
_cartState.value = it.data?.cartLinesUpdate?.cart.toUiState()
83+
_loadingState.value = false
84+
})
85+
}
86+
is CartState.Empty -> Log.e("CartViewModel", "attempting to update the quantity on an empty cart")
87+
}
88+
}
89+
7190
fun clearCart() {
7291
_cartState.value = CartState.Empty
7392
}
@@ -111,8 +130,10 @@ class CartViewModel(
111130
return CartState.Empty
112131
}
113132
val cartLines = lines.nodes.mapNotNull { cartLine ->
133+
val cartLineId = cartLine.id
114134
(cartLine.merchandise as? Storefront.ProductVariant)?.let {
115135
CartLine(
136+
id = cartLineId,
116137
title = it.product.title,
117138
vendor = it.product.vendor,
118139
quantity = cartLine.quantity

samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/common/client/StorefrontClient.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import com.shopify.buy3.Storefront
3030
import com.shopify.buy3.Storefront.CartBuyerIdentityInput
3131
import com.shopify.buy3.Storefront.CartInput
3232
import com.shopify.buy3.Storefront.CartLineInput
33+
import com.shopify.buy3.Storefront.CartLineUpdateInput
3334
import com.shopify.buy3.Storefront.CartQuery
3435
import com.shopify.buy3.Storefront.MutationQuery
3536
import com.shopify.buy3.Storefront.ProductVariantQuery
@@ -73,6 +74,29 @@ class StorefrontClient(private val client: GraphClient) {
7374
executeQuery(query, successCallback, failureCallback)
7475
}
7576

77+
fun cartLinesUpdate(
78+
cartId: ID,
79+
lineItemID: ID,
80+
quantity: Int,
81+
successCallback: (GraphResponse<Storefront.Mutation>) -> Unit,
82+
failureCallback: ((GraphError) -> Unit)? = {},
83+
) {
84+
val lineUpdateInput = CartLineUpdateInput(lineItemID).setQuantity(quantity)
85+
86+
val mutation = Storefront.mutation { mutation ->
87+
mutation.cartLinesUpdate(
88+
cartId,
89+
listOf(lineUpdateInput)
90+
) { cartLinesUpdate ->
91+
cartLinesUpdate.cart { cartQuery ->
92+
cartQueryFragment(cartQuery)
93+
}
94+
}
95+
}
96+
97+
executeMutation(mutation, successCallback, failureCallback)
98+
}
99+
76100
fun createCart(
77101
variant: Storefront.ProductVariant,
78102
buyerIdentity: CartBuyerIdentityInput?,
@@ -129,6 +153,7 @@ class StorefrontClient(private val client: GraphClient) {
129153
}
130154
.lines({ it.first(250) }) { lineQuery ->
131155
lineQuery.nodes { line ->
156+
line.id()
132157
line.quantity()
133158
line.merchandise { merchandise ->
134159
merchandise.onProductVariant { variant ->

samples/MobileBuyIntegration/app/src/main/java/com/shopify/checkout_sdk_mobile_buy_integration_sample/product/ProductView.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
*/
2323
package com.shopify.checkout_sdk_mobile_buy_integration_sample.product
2424

25-
import androidx.activity.ComponentActivity
2625
import androidx.compose.foundation.layout.Column
2726
import androidx.compose.foundation.layout.fillMaxHeight
2827
import androidx.compose.foundation.layout.fillMaxSize
@@ -39,11 +38,9 @@ import androidx.compose.runtime.LaunchedEffect
3938
import androidx.compose.runtime.collectAsState
4039
import androidx.compose.ui.Alignment
4140
import androidx.compose.ui.Modifier
42-
import androidx.compose.ui.platform.LocalContext
4341
import androidx.compose.ui.unit.dp
4442
import com.shopify.checkout_sdk_mobile_buy_integration_sample.AppBarState
4543
import com.shopify.checkout_sdk_mobile_buy_integration_sample.cart.CartViewModel
46-
import com.shopify.checkoutsheetkit.ShopifyCheckoutSheetKit
4744
import org.koin.androidx.compose.koinViewModel
4845

4946
@Composable

0 commit comments

Comments
 (0)