Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow selecting different variant options, respect available for sale #181

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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 @@ -47,6 +47,7 @@ import androidx.compose.ui.unit.sp
import com.shopify.checkout_sdk_mobile_buy_integration_sample.R
import com.shopify.checkout_sdk_mobile_buy_integration_sample.cart.data.CartLine
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.ID
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.BodySmall
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.MoneyText
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.QuantitySelector
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.RemoteImage
Expand Down Expand Up @@ -95,6 +96,7 @@ fun CartItem(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.secondary,
)
BodySmall(cartLine.variantDescription)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

display selected options on the cart page

Copy link

@leandrooriente leandrooriente Jan 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens with products without variantDescription?

edit:
nvm, it's a non-nullable attribute

}
QuantitySelector(enabled = !loading, quantity = cartLine.quantity) { quantity ->
modifyLineItem(cartLine.id, quantity)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ data class CartLine(
val title: String,
val vendor: String,
val quantity: Int,
val variantDescription: String,
val image: CartLineImage?,
val pricePerQuantity: Double,
val currencyPerQuantity: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ internal fun Storefront.BaseCartLine.toLocal(): CartLine? {
currencyPerQuantity = this.cost.amountPerQuantity.currencyCode.name,
totalPrice = this.cost.totalAmount.amount.toDouble(),
totalCurrency = this.cost.totalAmount.currencyCode.name,
variantDescription = it.selectedOptions.toDescription()
)
}
}

fun List<Storefront.SelectedOption>.toDescription(): String {
val optionsWithoutTitle = this.filter { option -> option.name != "Title" }
return optionsWithoutTitle.joinToString(separator = " / ") { option -> option.value }
}
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@ class CartStorefrontApiClient(
image.altText()
}
}
variant.selectedOptions { option ->
option.name()
option.value()
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ private val secondaryColor = Color(

private val DarkColorPalette = darkColorScheme(
primary = primaryColor,
onPrimary = Color.White
onPrimary = Color.White,
onBackground = Color.White,
)

private val LightColorPalette = lightColorScheme(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.shopify.checkout_sdk_mobile_buy_integration_sample.R
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.ID
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.MoneyText
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.MoneyRangeText
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.RemoteImage
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.ui.theme.defaultProductImageHeight
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.ui.theme.defaultProductImageHeightLg
Expand Down Expand Up @@ -79,9 +79,11 @@ fun ProductCollectionProduct(
maxLines = 1,
)

MoneyText(
currency = product.priceRange.maxVariantPrice.currencyCode,
price = product.priceRange.maxVariantPrice.amount,
MoneyRangeText(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should be a range for products with differently priced variants

fromPrice = product.priceRange.minVariantPrice.amount,
fromCurrencyCode = product.priceRange.minVariantPrice.currencyCode,
toPrice = product.priceRange.maxVariantPrice.amount,
toCurrencyCode = product.priceRange.maxVariantPrice.currencyCode,
style = MaterialTheme.typography.bodyMedium,
color = textColor,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.

@Composable
fun AddToCartButton(
enabled: Boolean,
loading: Boolean,
modifier: Modifier,
onClick: () -> Unit
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* MIT License
*
* Copyright 2023-present, Shopify Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.shopify.checkout_sdk_mobile_buy_integration_sample.products.product

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.BodyMedium
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.BodySmall
import com.shopify.checkout_sdk_mobile_buy_integration_sample.products.product.data.ProductVariantOptionDetails

@Composable
fun OptionSelector(
availableOptions: Map<String, List<ProductVariantOptionDetails>>,
selectedOptions: Map<String, String>,
onSelected: (name: String, value: String) -> Unit,
) {
availableOptions.forEach { (optionName, optionValues) ->
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {

BodySmall(optionName)

Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
optionValues.forEach { optionDetails ->
val isSelected = selectedOptions[optionName] == optionDetails.name

OutlinedButton(
onClick = { onSelected(optionName, optionDetails.name) },
enabled = optionDetails.availableForSale,
colors = if (isSelected) {
ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.onBackground)
} else {
ButtonDefaults.outlinedButtonColors(containerColor = Color.Unspecified)
}
) {
BodyMedium(
optionDetails.name,
color = if (isSelected) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.onBackground,
textDecoration = if (optionDetails.availableForSale) TextDecoration.None else TextDecoration.LineThrough,
)
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.shopify.checkout_sdk_mobile_buy_integration_sample.R
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.BodyMedium
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.BodySmall
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.Header2
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.MoneyText
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.components.ProgressIndicator
Expand Down Expand Up @@ -119,19 +118,23 @@ fun ProductView(
)

Column(verticalArrangement = Arrangement.spacedBy(5.dp)) {
// TODO deal with a variable amount of variants
val variant = product.variants!!.first()
val variant = productUIState.selectedVariant
MoneyText(variant.price.currencyCode, variant.price.amount)
BodySmall(
stringResource(id = R.string.product_taxes_included)
)
}

OptionSelector(
availableOptions = productUIState.availableOptions,
selectedOptions = productUIState.selectedVariant.selectedOptions.associate { it.name to it.value }
) { name, value ->
productViewModel.updateSelectedOption(name, value)
}

QuantitySelector(enabled = true, quantity = productUIState.addQuantityAmount) { quantity ->
productViewModel.setAddQuantityAmount(quantity)
}

AddToCartButton(
enabled = productUIState.selectedVariant.availableForSale,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disable add to cart if the selected variant is not available for sale

loading = productUIState.isAddingToCart,
modifier = Modifier.fillMaxWidth()
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import androidx.lifecycle.viewModelScope
import com.shopify.checkout_sdk_mobile_buy_integration_sample.cart.CartViewModel
import com.shopify.checkout_sdk_mobile_buy_integration_sample.products.product.data.Product
import com.shopify.checkout_sdk_mobile_buy_integration_sample.products.product.data.ProductRepository
import com.shopify.checkout_sdk_mobile_buy_integration_sample.products.product.data.ProductVariant
import com.shopify.checkout_sdk_mobile_buy_integration_sample.products.product.data.ProductVariantOptionDetails
import com.shopify.checkout_sdk_mobile_buy_integration_sample.products.product.data.ProductVariantSelectedOption
import com.shopify.graphql.support.ID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -51,11 +54,10 @@ class ProductViewModel(

fun addToCart() {
val state = _uiState.value
if (state is ProductUIState.Loaded && state.product.variants != null && state.product.selectedVariant != null) {
val selectedVariantId = state.product.variants[state.product.selectedVariant].id
if (state is ProductUIState.Loaded) {
val quantity = state.addQuantityAmount
setIsAddingToCart(true)
cartViewModel.addToCart(selectedVariantId, quantity) {
cartViewModel.addToCart(state.selectedVariant.id, quantity) {
setIsAddingToCart(false)
}
}
Expand All @@ -66,8 +68,11 @@ class ProductViewModel(
try {
val product = productRepository.getProduct(productId)
Timber.i("Fetching product complete $product")
val selectedVariant = product.variants.first()
_uiState.value = ProductUIState.Loaded(
product = product,
selectedVariant = selectedVariant,
availableOptions = buildAvailableOptions(product, selectedVariant),
isAddingToCart = false,
addQuantityAmount = 1
)
Expand All @@ -77,6 +82,49 @@ class ProductViewModel(
}
}

// Select a new variant option (e.g. size = large)
fun updateSelectedOption(name: String, value: String) {
val state = _uiState.value
if (state is ProductUIState.Loaded) {
val matchingVariant = state.product.variants.first { variant ->
variant.selectedOptions.containsAll(newOptions(state.selectedVariant, name, value))
}
matchingVariant.let {
_uiState.value = state.copy(
selectedVariant = it,
availableOptions = buildAvailableOptions(product = state.product, selectedVariant = it)
)
}
}
}

// Returns variant options for the product, and whether the option is available for sale (when combined with other options on the
// currently selected variant) e.g. { "size": [{"large", true}, {"medium", false}], "color": [{"red", true}, {"blue", false}]}
private fun buildAvailableOptions(product: Product, selectedVariant: ProductVariant): Map<String, List<ProductVariantOptionDetails>> {
// Only return available options if more than one option exists
if (product.variants.size == 1) {
return emptyMap()
}

val allOptions = product.variants.flatMap { variant -> variant.selectedOptions }
return allOptions.map { option -> option.name }.distinct().associateWith { optionName ->
allOptions.filter { option -> option.name == optionName }.map { it.value }.distinct().map { optionValue ->
val newOptions = newOptions(selectedVariant, optionName, optionValue)
ProductVariantOptionDetails(
name = optionValue,
availableForSale = product.variants.find { it.selectedOptions.containsAll(newOptions) }!!.availableForSale,
)
}
}
}

// Modifies the options for the selected variant (e.g: [size: large, color: red]) by replacing one with a new option (e.g. color: blue)
// to return e.g. [size: large, color: blue]
private fun newOptions(selectedVariant: ProductVariant, name: String, value: String): List<ProductVariantSelectedOption> =
selectedVariant.selectedOptions
.filter { it.name != name }
.plus(ProductVariantSelectedOption(name, value))

private fun setIsAddingToCart(value: Boolean) {
val currentState = _uiState.value
if (currentState is ProductUIState.Loaded) {
Expand All @@ -89,5 +137,11 @@ class ProductViewModel(
sealed class ProductUIState {
data object Loading : ProductUIState()
data class Error(val error: String) : ProductUIState()
data class Loaded(val product: Product, val isAddingToCart: Boolean, val addQuantityAmount: Int) : ProductUIState()
data class Loaded(
val product: Product,
val selectedVariant: ProductVariant,
val availableOptions: Map<String, List<ProductVariantOptionDetails>>,
val isAddingToCart: Boolean,
val addQuantityAmount: Int
) : ProductUIState()
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,6 @@ import com.shopify.buy3.Storefront
import com.shopify.checkout_sdk_mobile_buy_integration_sample.common.toLocal

fun Storefront.Product.toLocal(): Product {
val variants = this.variants
val firstVariant = variants?.nodes?.firstOrNull()
val uiProduct = Product(
id = id.toLocal(),
title = title,
Expand All @@ -48,19 +46,23 @@ fun Storefront.Product.toLocal(): Product {
amount = priceRange.maxVariantPrice.amount.toDouble()
)
),
variants = if (firstVariant != null) {
mutableListOf(
ProductVariant(
id = firstVariant.id.toLocal(),
price = ProductPriceAmount(
amount = firstVariant.price.amount.toDouble(),
currencyCode = firstVariant.price.currencyCode.name,
variants = variants?.nodes?.map { variant ->
ProductVariant(
id = variant.id.toLocal(),
price = ProductPriceAmount(
amount = variant.price.amount.toDouble(),
currencyCode = variant.price.currencyCode.name,
),
title = variant.title,
availableForSale = variant.availableForSale,
selectedOptions = variant.selectedOptions.map { option ->
ProductVariantSelectedOption(
option.name,
option.value
)
)
}
)
} else {
mutableListOf()
}
} ?: mutableListOf()
)
return uiProduct
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,25 @@ data class Product(
val description: String,
val image: ProductImage?,
val priceRange: ProductPriceRange,
val variants: List<ProductVariant>? = mutableListOf(),
val selectedVariant: Int? = 0,
val variants: List<ProductVariant> = mutableListOf(),
)

data class ProductVariant(
val id: ID,
val price: ProductPriceAmount,
val availableForSale: Boolean,
val title: String,
val selectedOptions: List<ProductVariantSelectedOption>,
)

data class ProductVariantSelectedOption(
val name: String,
val value: String,
)

data class ProductVariantOptionDetails(
val name: String,
val availableForSale: Boolean,
)

data class ProductPriceRange(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ class ProductRepository(

suspend fun getProduct(productId: ID): Product {
return suspendCoroutine { continuation ->
client.fetchProduct(productId = productId, numVariants = 1, { result ->
client.fetchProduct(productId = productId, numVariants = 20, { result ->
val product = result.data?.product
if (product == null) {
continuation.resumeWith(Result.failure(RuntimeException("Failed to fetch product")))
Expand All @@ -52,14 +52,14 @@ class ProductRepository(
if (products == null) {
continuation.resumeWith(Result.failure(RuntimeException("Failed to fetch products")))
} else {
val products = Products(
val result = Products(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small fix for the var name products being shadowed

products = products.edges.map { it.node.toLocal() },
pageInfo = PageInfo(
startCursor = products.pageInfo.startCursor,
endCursor = products.pageInfo.endCursor,
)
)
continuation.resumeWith(Result.success(products))
continuation.resumeWith(Result.success(result))
}
}, { exception ->
continuation.resumeWith(Result.failure(exception))
Expand Down
Loading
Loading