diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/SaveAiGeneratedProduct.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/SaveAiGeneratedProduct.kt index ed660c9ede78..e3b3c7c70d8f 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/SaveAiGeneratedProduct.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/SaveAiGeneratedProduct.kt @@ -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 { - // 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) = 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) = 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 + } } diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModel.kt index ee8e637d81ec..93de74cbd647 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModel.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModel.kt @@ -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 @@ -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 @@ -229,12 +227,10 @@ 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, @@ -242,39 +238,37 @@ class AiProductPreviewViewModel @Inject constructor( ?: 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 + (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 { - 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) { diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModelTest.kt index df762f4ee72b..1e4b9c965c45 100644 --- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModelTest.kt +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/ai/preview/AiProductPreviewViewModelTest.kt @@ -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 @@ -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 @@ -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() @@ -73,7 +70,6 @@ class AiProductPreviewViewModelTest : BaseUnitTest() { savedStateHandle = args.toSavedStateHandle(), buildProductPreviewProperties = buildProductPreviewProperties, generateProductWithAI = generateProductWithAI, - uploadImage = uploadImage, analyticsTracker = analyticsTracker, saveAiGeneratedProduct = saveAiGeneratedProduct, resourceProvider = resourceProvider, @@ -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( @@ -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 { @@ -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() @@ -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 {