Skip to content
Merged
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
@@ -1,70 +1,114 @@
package com.woocommerce.android.ui.products.ai

import com.woocommerce.android.model.Image
import com.woocommerce.android.model.Product
import com.woocommerce.android.model.ProductCategory
import com.woocommerce.android.model.ProductTag
import com.woocommerce.android.ui.products.ProductStatus
import com.woocommerce.android.ui.products.ai.preview.UploadImage
import com.woocommerce.android.ui.products.categories.ProductCategoriesRepository
import com.woocommerce.android.ui.products.details.ProductDetailRepository
import com.woocommerce.android.ui.products.tags.ProductTagsRepository
import com.woocommerce.android.util.WooLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import javax.inject.Inject

class SaveAiGeneratedProduct @Inject constructor(
private val productCategoriesRepository: ProductCategoriesRepository,
private val productTagsRepository: ProductTagsRepository,
private val productDetailRepository: ProductDetailRepository
private val productDetailRepository: ProductDetailRepository,
private val uploadImage: UploadImage
) {
@Suppress("ReturnCount")
suspend operator fun invoke(
product: Product,
selectedImage: Product.Image?
): Result<Long> {
// Create missing categories
selectedImage: Image?
): AiProductSaveResult = coroutineScope {
// Start uploading the selected image
val imageTask = selectedImage?.let { selectedImage -> startUploadingImage(selectedImage) }

val missingCategories = product.categories.filter { it.remoteCategoryId == 0L }
val createdCategories = missingCategories
.takeIf { it.isNotEmpty() }?.let { productCategories ->
WooLog.d(
tag = WooLog.T.PRODUCTS,
message = "Create the missing product categories ${productCategories.map { it.name }}"
)
productCategoriesRepository.addProductCategories(productCategories)
}?.fold(
onSuccess = { it },
onFailure = {
WooLog.e(WooLog.T.PRODUCTS, "Failed to add product categories", it)
return Result.failure(it)
}
)
// Start create missing categories
val categoriesTask = missingCategories
.takeIf { it.isNotEmpty() }
?.let { productCategories -> startCreatingCategories(productCategories) }

// Create missing tags
// Start Create missing tags
val missingTags = product.tags.filter { it.remoteTagId == 0L }
val createdTags = missingTags
.takeIf { it.isNotEmpty() }?.let { productTags ->
WooLog.d(
tag = WooLog.T.PRODUCTS,
message = "Create the missing product tags ${productTags.map { it.name }}"
)
productTagsRepository.addProductTags(productTags.map { it.name })
}?.fold(
onSuccess = { it },
onFailure = {
WooLog.e(WooLog.T.PRODUCTS, "Failed to add product tags", it)
return Result.failure(it)
}
)
val tagsTask = missingTags
.takeIf { it.isNotEmpty() }
?.let { productTags -> startCreatingTags(productTags) }

// Wait for the image to be uploaded
val image = imageTask?.await()?.getOrElse {
return@coroutineScope AiProductSaveResult.Failure.UploadImageFailure
}

// Wait for the created categories and tags
val createdCategories = categoriesTask?.await()?.getOrElse {
return@coroutineScope AiProductSaveResult.Failure.Generic(image?.asWPMediaLibraryImage())
}
val createdTags = tagsTask?.await()?.getOrElse {
return@coroutineScope AiProductSaveResult.Failure.Generic(image?.asWPMediaLibraryImage())
}

val updatedProduct = product.copy(
categories = product.categories - missingCategories.toSet() + createdCategories.orEmpty(),
tags = product.tags - missingTags.toSet() + createdTags.orEmpty(),
images = listOfNotNull(selectedImage),
images = listOfNotNull(image),
status = ProductStatus.DRAFT
)

return productDetailRepository.addProduct(updatedProduct).let { (success, productId) ->
productDetailRepository.addProduct(updatedProduct).let { (success, productId) ->
if (success) {
Result.success(productId)
WooLog.d(
tag = WooLog.T.PRODUCTS,
message = "Successfully saved the AI generated product as draft with id $productId"
)
AiProductSaveResult.Success(productId)
} else {
Result.failure(Exception("Failed to save the AI generated product"))
WooLog.e(WooLog.T.PRODUCTS, "Failed to save the AI generated product as draft")
AiProductSaveResult.Failure.Generic(image?.asWPMediaLibraryImage())
}
}
}

private fun CoroutineScope.startUploadingImage(image: Image) = async {
uploadImage(image).onFailure {
WooLog.e(WooLog.T.PRODUCTS, "Failed to upload the selected image", it)
}
}

private fun CoroutineScope.startCreatingCategories(categories: List<ProductCategory>) = async {
WooLog.d(
tag = WooLog.T.PRODUCTS,
message = "Create the missing product categories ${categories.map { it.name }}"
)
productCategoriesRepository.addProductCategories(categories)
.onFailure {
WooLog.e(WooLog.T.PRODUCTS, "Failed to add product categories", it)
}
}

private fun CoroutineScope.startCreatingTags(tags: List<ProductTag>) = async {
WooLog.d(
tag = WooLog.T.PRODUCTS,
message = "Create the missing product tags ${tags.map { it.name }}"
)
productTagsRepository.addProductTags(tags.map { it.name })
.onFailure {
WooLog.e(WooLog.T.PRODUCTS, "Failed to add product tags", it)
}
}

private fun Product.Image.asWPMediaLibraryImage() = Image.WPMediaLibraryImage(this)
}

sealed interface AiProductSaveResult {
data class Success(val productId: Long) : AiProductSaveResult
sealed interface Failure : AiProductSaveResult {
data object UploadImageFailure : Failure
data class Generic(val uploadedImage: Image.WPMediaLibraryImage? = null) : Failure
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@ import com.woocommerce.android.analytics.AnalyticsTracker
import com.woocommerce.android.analytics.AnalyticsTrackerWrapper
import com.woocommerce.android.extensions.combine
import com.woocommerce.android.model.Image
import com.woocommerce.android.model.Image.WPMediaLibraryImage
import com.woocommerce.android.model.Product
import com.woocommerce.android.ui.products.ai.AIProductModel
import com.woocommerce.android.ui.products.ai.AiProductSaveResult
import com.woocommerce.android.ui.products.ai.BuildProductPreviewProperties
import com.woocommerce.android.ui.products.ai.ProductPropertyCard
import com.woocommerce.android.ui.products.ai.SaveAiGeneratedProduct
Expand Down Expand Up @@ -42,7 +41,6 @@ class AiProductPreviewViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val buildProductPreviewProperties: BuildProductPreviewProperties,
private val generateProductWithAI: GenerateProductWithAI,
private val uploadImage: UploadImage,
private val analyticsTracker: AnalyticsTrackerWrapper,
private val saveAiGeneratedProduct: SaveAiGeneratedProduct,
private val resourceProvider: ResourceProvider
Expand Down Expand Up @@ -229,52 +227,48 @@ class AiProductPreviewViewModel @Inject constructor(
val product = generatedProduct.value?.getOrNull()?.toProduct(selectedVariant.value) ?: return
savingProductState.value = SavingProductState.Loading
viewModelScope.launch {
val image = uploadSelectedImage().onFailure {
return@launch
}.getOrNull()
val image = imageState.value.image
val editedFields = userEditedFields.value

saveAiGeneratedProduct(
val result = saveAiGeneratedProduct(
product.copy(
name = editedFields.names[selectedVariant.value] ?: product.name,
description = editedFields.descriptions[selectedVariant.value] ?: product.description,
shortDescription = editedFields.shortDescriptions[selectedVariant.value]
?: product.shortDescription
),
image
).fold(
onSuccess = { productId ->
)

when (result) {
is AiProductSaveResult.Success -> {
savingProductState.value = SavingProductState.Success
triggerEvent(NavigateToProductDetailScreen(productId))
triggerEvent(NavigateToProductDetailScreen(result.productId))
analyticsTracker.track(AnalyticsEvent.PRODUCT_CREATION_AI_SAVE_AS_DRAFT_SUCCESS)
},
onFailure = {
}

is AiProductSaveResult.Failure -> {
// Keep track of the uploaded image to avoid re-uploading it on retry
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice 💯

(result as? AiProductSaveResult.Failure.Generic)?.uploadedImage?.let {
imageState.value = imageState.value.copy(image = it)
}

val messageRes = when (result) {
is AiProductSaveResult.Failure.UploadImageFailure ->
R.string.ai_product_creation_error_media_upload

else -> R.string.error_generic
}

savingProductState.value = SavingProductState.Error(
messageRes = R.string.error_generic,
messageRes = messageRes,
onRetryClick = ::onSaveProductAsDraft,
onDismissClick = { savingProductState.value = SavingProductState.Idle }
)
analyticsTracker.track(AnalyticsEvent.PRODUCT_CREATION_AI_SAVE_AS_DRAFT_FAILED)
}
)
}
}

private suspend fun uploadSelectedImage(): Result<Product.Image?> {
val image = imageState.value.image ?: return Result.success(null)
return uploadImage(image)
.onSuccess {
imageState.value = imageState.value.copy(
image = WPMediaLibraryImage(content = it)
)
}
.onFailure {
savingProductState.value = SavingProductState.Error(
messageRes = R.string.ai_product_creation_error_media_upload,
onRetryClick = ::onSaveProductAsDraft,
onDismissClick = { savingProductState.value = SavingProductState.Idle }
)
}
}
}

private fun trackUndoEditClick(field: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.woocommerce.android.analytics.AnalyticsTrackerWrapper
import com.woocommerce.android.model.Image
import com.woocommerce.android.model.Product
import com.woocommerce.android.ui.products.ai.AIProductModel
import com.woocommerce.android.ui.products.ai.AiProductSaveResult
import com.woocommerce.android.ui.products.ai.BuildProductPreviewProperties
import com.woocommerce.android.ui.products.ai.SaveAiGeneratedProduct
import com.woocommerce.android.ui.products.ai.components.ImageAction
Expand All @@ -20,9 +21,11 @@ import org.assertj.core.api.Assertions.assertThat
import org.junit.Test
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.argThat
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doSuspendableAnswer
import org.mockito.kotlin.mock
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import java.util.Date
Expand All @@ -42,16 +45,10 @@ class AiProductPreviewViewModelTest : BaseUnitTest() {
Result.success(SAMPLE_PRODUCT)
}
}
private val uploadImage: UploadImage = mock {
onBlocking { invoke(any()) } doSuspendableAnswer {
delay(100)
Result.success(SAMPLE_UPLOADED_IMAGE)
}
}
private val saveAiGeneratedProduct: SaveAiGeneratedProduct = mock {
onBlocking { invoke(any(), anyOrNull()) } doSuspendableAnswer {
delay(100)
Result.success(1L)
AiProductSaveResult.Success(1L)
}
}
private val analyticsTracker: AnalyticsTrackerWrapper = mock()
Expand All @@ -73,7 +70,6 @@ class AiProductPreviewViewModelTest : BaseUnitTest() {
savedStateHandle = args.toSavedStateHandle(),
buildProductPreviewProperties = buildProductPreviewProperties,
generateProductWithAI = generateProductWithAI,
uploadImage = uploadImage,
analyticsTracker = analyticsTracker,
saveAiGeneratedProduct = saveAiGeneratedProduct,
resourceProvider = resourceProvider,
Expand Down Expand Up @@ -226,30 +222,6 @@ class AiProductPreviewViewModelTest : BaseUnitTest() {
assertThat(event).isInstanceOf(MultiLiveEvent.Event.ShowUndoSnackbar::class.java)
}

@Test
fun `given a local image, when the user taps on save, then upload the image`() = testBlocking {
setup(
args = AiProductPreviewFragmentArgs(
productFeatures = PRODUCT_FEATURES,
image = Image.LocalImage("path")
)
)

val viewState = viewModel.state.runAndCaptureValues {
advanceUntilIdle()
viewModel.onSaveProductAsDraft()
advanceUntilIdle()
}.last()

verify(uploadImage).invoke(Image.LocalImage("path"))
val successState = viewState as AiProductPreviewViewModel.State.Success
assertThat(successState.imageState).isEqualTo(
AiProductPreviewViewModel.ImageState(
image = Image.WPMediaLibraryImage(SAMPLE_UPLOADED_IMAGE)
)
)
}

@Test
fun `given a local image, when the image upload fails, then show an error`() = testBlocking {
setup(
Expand All @@ -258,7 +230,8 @@ class AiProductPreviewViewModelTest : BaseUnitTest() {
image = Image.LocalImage("path")
)
) {
whenever(uploadImage.invoke(any())).thenReturn(Result.failure(Exception()))
whenever(saveAiGeneratedProduct.invoke(any(), any()))
.thenReturn(AiProductSaveResult.Failure.UploadImageFailure)
}

val viewState = viewModel.state.runAndCaptureValues {
Expand All @@ -274,6 +247,29 @@ class AiProductPreviewViewModelTest : BaseUnitTest() {
}
}

@Test
fun `given a local image uploaded successfully, when retrying after an error, then don't reupload the image`() =
testBlocking {
setup(
args = AiProductPreviewFragmentArgs(
productFeatures = PRODUCT_FEATURES,
image = Image.LocalImage("path")
)
) {
whenever(saveAiGeneratedProduct.invoke(any(), anyOrNull()))
.thenReturn(AiProductSaveResult.Failure.Generic(Image.WPMediaLibraryImage(SAMPLE_UPLOADED_IMAGE)))
.thenReturn(AiProductSaveResult.Success(1L))
}

advanceUntilIdle()
viewModel.onSaveProductAsDraft()
advanceUntilIdle()
viewModel.onSaveProductAsDraft()

verify(saveAiGeneratedProduct, times(1)).invoke(any(), argThat { this is Image.LocalImage })
verify(saveAiGeneratedProduct, times(1)).invoke(any(), argThat { this is Image.WPMediaLibraryImage })
}

@Test
fun `when product is saved successfully, then navigate to the product details`() = testBlocking {
setup()
Expand All @@ -290,7 +286,12 @@ class AiProductPreviewViewModelTest : BaseUnitTest() {
@Test
fun `when product saving fails, then show an error`() = testBlocking {
setup {
whenever(saveAiGeneratedProduct.invoke(any(), anyOrNull())).thenReturn(Result.failure(Exception()))
whenever(
saveAiGeneratedProduct.invoke(
any(),
anyOrNull()
)
).thenReturn(AiProductSaveResult.Failure.Generic())
}

val viewState = viewModel.state.runAndCaptureValues {
Expand Down