diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailImageHeader.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailImageHeader.kt new file mode 100644 index 00000000000..9fd30a3924e --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailImageHeader.kt @@ -0,0 +1,196 @@ +package com.woocommerce.android.ui.products.details + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.woocommerce.android.R +import com.woocommerce.android.ui.compose.animations.SkeletonView +import com.woocommerce.android.ui.compose.designsystem.WooTheme +import com.woocommerce.android.ui.compose.designsystem.component.WooNoticeBanner +import com.woocommerce.android.ui.compose.designsystem.component.WooNoticeBannerTone +import com.woocommerce.android.ui.compose.designsystem.component.WooOutlinedButton +import com.woocommerce.android.ui.compose.designsystem.foundation.WooDesignSystemThemeWithBackground +import com.woocommerce.android.ui.compose.designsystem.icons.CircleInfo +import com.woocommerce.android.ui.compose.designsystem.icons.WooIcons +import kotlinx.coroutines.delay + +@Composable +fun ProductDetailImageHeader( + state: ProductDetailImageUiState, + onAddImageClicked: () -> Unit, + onImagesUnavailableClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + color = WooTheme.colors.surface.bright, + modifier = modifier + .fillMaxWidth() + .heightIn(min = dimensionResource(R.dimen.image_major_120)), + ) { + when (state) { + ProductDetailImageUiState.Loading -> ProductDetailImageLoading() + ProductDetailImageUiState.AddImage -> Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = dimensionResource(R.dimen.image_major_120)) + .clickable(onClick = onAddImageClicked) + .padding(WooTheme.padding.padding5), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + painter = painterResource(R.drawable.ic_gridicons_add_image), + contentDescription = null, + tint = WooTheme.colors.primary, + modifier = Modifier.size(ADD_IMAGE_ICON_SIZE), + ) + Text( + text = stringResource(R.string.product_image_add), + color = WooTheme.colors.surface.onDefault, + style = WooTheme.text.bodyMedium.emphasized, + textAlign = TextAlign.Center, + ) + } + ProductDetailImageUiState.Unavailable -> Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = dimensionResource(R.dimen.image_major_120)) + .clickable(onClick = onImagesUnavailableClicked) + .padding(WooTheme.padding.padding4), + contentAlignment = Alignment.Center, + ) { + WooNoticeBanner( + title = AnnotatedString.fromHtml(stringResource(R.string.images_unavailable_notice)).text, + tone = WooNoticeBannerTone.Warning, + leadingIcon = { + Icon( + imageVector = WooIcons.Regular.CircleInfo, + contentDescription = null, + ) + }, + ) + } + ProductDetailImageUiState.Gallery, + ProductDetailImageUiState.Hidden -> Unit + } + } +} + +@Composable +private fun ProductDetailImageLoading() { + var isVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(LOADING_DELAY_MS) + isVisible = true + } + if (isVisible) { + SkeletonView( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = dimensionResource(R.dimen.image_major_120)) + .padding(WooTheme.padding.padding5) + .clip(RoundedCornerShape(WooTheme.radius.medium)), + ) + } +} + +@Composable +fun ProductDetailUploadError( + isVisible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (isVisible) { + Surface( + color = WooTheme.colors.surface.bright, + modifier = modifier.fillMaxWidth(), + ) { + WooOutlinedButton( + text = stringResource(R.string.product_open_upload_screen), + onClick = onClick, + modifier = Modifier + .padding( + horizontal = WooTheme.padding.padding7, + vertical = WooTheme.padding.padding3, + ), + ) + } + } +} + +@PreviewLightDark +@Composable +private fun ProductDetailAddImagePreview() { + WooDesignSystemThemeWithBackground { + ProductDetailImageHeader( + state = ProductDetailImageUiState.AddImage, + onAddImageClicked = {}, + onImagesUnavailableClicked = {}, + modifier = Modifier.width(360.dp), + ) + } +} + +@PreviewLightDark +@Composable +private fun ProductDetailImageUnavailablePreview() { + WooDesignSystemThemeWithBackground { + ProductDetailImageHeader( + state = ProductDetailImageUiState.Unavailable, + onAddImageClicked = {}, + onImagesUnavailableClicked = {}, + modifier = Modifier.width(360.dp), + ) + } +} + +@PreviewLightDark +@Composable +private fun ProductDetailImageLoadingPreview() { + WooDesignSystemThemeWithBackground { + ProductDetailImageHeader( + state = ProductDetailImageUiState.Loading, + onAddImageClicked = {}, + onImagesUnavailableClicked = {}, + modifier = Modifier.size(width = 360.dp, height = 120.dp), + ) + } +} + +@PreviewLightDark +@Composable +private fun ProductDetailUploadErrorPreview() { + WooDesignSystemThemeWithBackground { + ProductDetailUploadError(isVisible = true, onClick = {}) + } +} + +private val ADD_IMAGE_ICON_SIZE = 40.dp +private const val LOADING_DELAY_MS = 250L diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailPreviewData.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailPreviewData.kt new file mode 100644 index 00000000000..d3be0f4f90f --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailPreviewData.kt @@ -0,0 +1,143 @@ +package com.woocommerce.android.ui.products.details + +import com.woocommerce.android.R + +internal object ProductDetailPreviewData { + private val addRows = listOf( + ProductDetailRowUiModel.Editable( + key = "title", + hint = R.string.product_detail_title_hint, + text = "", + shouldFocus = false, + isReadOnly = false, + badgeText = null, + badgeTone = null, + onTextChanged = {}, + ), + ProductDetailRowUiModel.ComplexProperty( + key = "description", + title = R.string.product_description, + value = "Describe your product", + icon = null, + showTitle = false, + maxLines = 1, + showDivider = false, + onClick = {}, + ), + ProductDetailRowUiModel.Button( + key = "write_with_ai", + text = R.string.product_sharing_write_with_ai, + icon = R.drawable.ic_ai, + showDivider = true, + tooltip = null, + link = ProductDetailButtonLinkUiModel( + text = R.string.ai_product_description_learn_more_link, + onClick = {}, + ), + onClick = {}, + ), + ) + + private val addDetails = listOf( + ProductDetailRowUiModel.PropertyGroup( + key = "price", + title = R.string.product_price, + properties = listOf(ProductDetailPropertyValueUiModel("", "Add price")), + icon = R.drawable.ic_gridicons_money, + showTitle = false, + showDivider = true, + isHighlighted = false, + propertyFormat = R.string.product_property_default_formatter, + onClick = {}, + ), + ProductDetailRowUiModel.PropertyGroup( + key = "inventory", + title = R.string.product_inventory, + properties = listOf(ProductDetailPropertyValueUiModel("Stock status", "In stock")), + icon = R.drawable.ic_gridicons_list_checkmark, + showTitle = true, + showDivider = true, + isHighlighted = false, + propertyFormat = R.string.product_property_default_formatter, + onClick = {}, + ), + ProductDetailRowUiModel.ComplexProperty( + key = "type", + title = R.string.product_type, + value = "Physical product", + icon = R.drawable.ic_gridicons_product, + showTitle = true, + maxLines = 1, + showDivider = false, + onClick = null, + ), + ) + + val addProductState = ProductDetailScreenState.Content( + cards = listOf( + ProductDetailCardUiModel("primary", ProductDetailCardStyle.PRIMARY, "", addRows), + ProductDetailCardUiModel("details", ProductDetailCardStyle.SECONDARY, "", addDetails), + ), + showAddMore = true, + showLinkedProductPromo = false, + ) + + val existingProductState = ProductDetailScreenState.Content( + cards = listOf( + ProductDetailCardUiModel( + key = "primary", + style = ProductDetailCardStyle.PRIMARY, + caption = "", + rows = listOf( + addRows.first().let { (it as ProductDetailRowUiModel.Editable).copy(text = "Beanie") }, + (addRows[1] as ProductDetailRowUiModel.ComplexProperty).copy( + value = "A warm beanie for every season.", + showTitle = true, + ), + addRows[2], + ), + ), + ProductDetailCardUiModel( + key = "details", + style = ProductDetailCardStyle.SECONDARY, + caption = "", + rows = addDetails + ProductDetailRowUiModel.Rating( + key = "reviews", + title = R.string.product_reviews, + value = "6 approved reviews", + rating = 4.5f, + icon = R.drawable.ic_reviews, + showDivider = true, + onClick = {}, + ), + ), + ), + showAddMore = true, + showLinkedProductPromo = true, + ) + + val warningState = ProductDetailScreenState.Content( + cards = listOf( + ProductDetailCardUiModel( + key = "warning", + style = ProductDetailCardStyle.SECONDARY, + caption = "", + rows = listOf( + ProductDetailRowUiModel.Warning("warning", "Some variations are missing a price."), + ProductDetailRowUiModel.ComplexProperty( + key = "variations", + title = R.string.product_variations, + value = "3 variations", + icon = R.drawable.ic_gridicons_types, + showTitle = true, + maxLines = 1, + showDivider = false, + onClick = {}, + ), + ), + ), + ), + showAddMore = true, + showLinkedProductPromo = false, + ) +} diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRows.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRows.kt new file mode 100644 index 00000000000..d590b3a71e5 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailRows.kt @@ -0,0 +1,698 @@ +package com.woocommerce.android.ui.products.details + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.woocommerce.android.R +import com.woocommerce.android.ui.compose.designsystem.WooTheme +import com.woocommerce.android.ui.compose.designsystem.component.WooBadge +import com.woocommerce.android.ui.compose.designsystem.component.WooBadgeTone +import com.woocommerce.android.ui.compose.designsystem.component.WooButtonSize +import com.woocommerce.android.ui.compose.designsystem.component.WooCell +import com.woocommerce.android.ui.compose.designsystem.component.WooCellTrailingAffordance +import com.woocommerce.android.ui.compose.designsystem.component.WooDivider +import com.woocommerce.android.ui.compose.designsystem.component.WooFilledTonalButton +import com.woocommerce.android.ui.compose.designsystem.component.WooNoticeBanner +import com.woocommerce.android.ui.compose.designsystem.component.WooNoticeBannerTone +import com.woocommerce.android.ui.compose.designsystem.component.WooSwitch +import com.woocommerce.android.ui.compose.designsystem.icons.AngleRight +import com.woocommerce.android.ui.compose.designsystem.icons.Star +import com.woocommerce.android.ui.compose.designsystem.icons.WooIcons + +@Composable +internal fun ProductDetailRow(row: ProductDetailRowUiModel) { + val modifier = Modifier.testTag(ProductDetailTestTags.row(row.key)) + when (row) { + is ProductDetailRowUiModel.Divider -> WooDivider(modifier) + is ProductDetailRowUiModel.Property -> ProductDetailPropertyRow(row, modifier) + is ProductDetailRowUiModel.ComplexProperty -> ProductDetailComplexPropertyRow(row, modifier) + is ProductDetailRowUiModel.Rating -> ProductDetailRatingRow(row, modifier) + is ProductDetailRowUiModel.Editable -> ProductDetailEditableRow(row, modifier) + is ProductDetailRowUiModel.PropertyGroup -> ProductDetailPropertyGroupRow(row, modifier) + is ProductDetailRowUiModel.Link -> ProductDetailLinkRow(row, modifier) + is ProductDetailRowUiModel.Button -> ProductDetailButtonRow(row, modifier) + is ProductDetailRowUiModel.Switch -> ProductDetailSwitchRow(row, modifier) + is ProductDetailRowUiModel.Warning -> WooNoticeBanner( + title = row.content, + tone = WooNoticeBannerTone.Warning, + modifier = modifier.padding(WooTheme.padding.padding5), + ) + } +} + +@Composable +private fun ProductDetailPropertyRow( + row: ProductDetailRowUiModel.Property, + modifier: Modifier, +) { + Column(modifier = modifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = MIN_ROW_HEIGHT) + .padding(horizontal = WooTheme.padding.padding7, vertical = WooTheme.padding.padding4), + horizontalArrangement = Arrangement.spacedBy(WooTheme.spacing.space4), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(row.title), + color = WooTheme.colors.surface.onDefault, + style = WooTheme.text.bodyLarge.emphasized, + modifier = Modifier.weight(1f), + ) + Text( + text = row.value, + color = WooTheme.colors.surface.onVariant, + style = WooTheme.text.bodyLarge.regular, + ) + } + ProductDetailOptionalDivider( + show = row.showDivider, + ) + } +} + +@Composable +private fun ProductDetailComplexPropertyRow( + row: ProductDetailRowUiModel.ComplexProperty, + modifier: Modifier, +) { + val title = row.title?.let { stringResource(it) }.orEmpty() + val value = AnnotatedString.fromHtml(row.value) + Column { + ProductDetailPropertyCell( + title = if (row.showTitle && row.title != null) title else value.text, + description = value.takeIf { row.showTitle && row.title != null }, + icon = row.icon, + maxLines = row.maxLines, + onClick = row.onClick, + modifier = modifier, + ) + ProductDetailOptionalDivider( + show = row.showDivider, + hasLeadingIcon = row.icon != null, + ) + } +} + +@Composable +private fun ProductDetailRatingRow( + row: ProductDetailRowUiModel.Rating, + modifier: Modifier, +) { + Column { + ProductDetailPropertyCell( + title = stringResource(row.title), + description = null, + icon = row.icon, + onClick = row.onClick, + modifier = modifier, + additionalContent = { + ProductDetailRatingSummary( + rating = row.rating, + reviewCount = row.value, + ) + }, + ) + ProductDetailOptionalDivider( + show = row.showDivider, + hasLeadingIcon = true, + ) + } +} + +@Composable +private fun ProductDetailEditableRow( + row: ProductDetailRowUiModel.Editable, + modifier: Modifier, +) { + Column { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = MIN_EDITABLE_HEIGHT) + .padding(horizontal = WooTheme.padding.padding7, vertical = WooTheme.padding.padding3), + horizontalArrangement = Arrangement.spacedBy(WooTheme.spacing.space3), + verticalAlignment = Alignment.CenterVertically, + ) { + ProductDetailEditableField(row, Modifier.weight(1f)) + ProductDetailEditableBadge(row) + } + WooDivider() + } +} + +@Composable +private fun ProductDetailEditableField( + row: ProductDetailRowUiModel.Editable, + modifier: Modifier, +) { + val callback by rememberUpdatedState(row.onTextChanged) + val focusRequester = remember(row.key) { FocusRequester() } + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + var isFocused by remember(row.key) { mutableStateOf(false) } + var restoreFocus by rememberSaveable(row.key) { mutableStateOf(false) } + var hasEditedWhileFocused by rememberSaveable(row.key) { mutableStateOf(false) } + var value by rememberSaveable(row.key, stateSaver = TextFieldValue.Saver) { + mutableStateOf(titleFieldValue(row.text, moveCursorToEnd = row.shouldFocus)) + } + val shouldRestoreFocus = restoreFocus + + LaunchedEffect(row.text, isFocused, restoreFocus, hasEditedWhileFocused) { + val synchronizedState = synchronizeTitleFieldState( + externalText = row.text, + isFocused = isFocused, + shouldFocus = row.shouldFocus, + currentState = ProductDetailTitleFieldState( + value = value, + restoreFocus = restoreFocus, + hasEditedWhileFocused = hasEditedWhileFocused, + ), + ) + value = synchronizedState.value + restoreFocus = synchronizedState.restoreFocus + hasEditedWhileFocused = synchronizedState.hasEditedWhileFocused + } + LaunchedEffect(row.shouldFocus, row.isReadOnly) { + if (row.shouldFocus && !row.isReadOnly) focusRequester.requestFocus() + } + LaunchedEffect(Unit) { + if (shouldRestoreFocus && !row.isReadOnly) focusRequester.requestFocus() + } + + fun finishEditing() { + restoreFocus = false + hasEditedWhileFocused = false + keyboardController?.hide() + focusManager.clearFocus() + } + + BasicTextField( + value = value, + onValueChange = { updatedValue -> + val textChanged = updatedValue.text != value.text + value = updatedValue + if (textChanged) { + hasEditedWhileFocused = hasEditedWhileFocused || isFocused + callback?.invoke(updatedValue.text) + } + }, + modifier = modifier + .focusRequester(focusRequester) + .onFocusChanged { + isFocused = it.isFocused + if (it.isFocused) { + restoreFocus = true + } + } + .onPreviewKeyEvent { event -> + if (event.key == Key.Enter) { + finishEditing() + true + } else { + false + } + } + .testTag(ProductDetailTestTags.TITLE), + enabled = !row.isReadOnly, + singleLine = true, + textStyle = WooTheme.text.titleLarge.regular.copy(color = WooTheme.colors.surface.onDefault), + cursorBrush = SolidColor(WooTheme.colors.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { finishEditing() }), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (value.text.isEmpty()) { + Text( + text = stringResource(row.hint), + color = WooTheme.colors.surface.onVariant, + style = WooTheme.text.titleLarge.regular, + ) + } + innerTextField() + } + }, + ) +} + +@Composable +private fun ProductDetailEditableBadge(row: ProductDetailRowUiModel.Editable) { + if (row.badgeText != null && row.badgeTone != null) { + WooBadge( + text = stringResource(row.badgeText), + tone = when (row.badgeTone) { + ProductDetailBadgeTone.NEUTRAL -> WooBadgeTone.Neutral + ProductDetailBadgeTone.WARNING -> WooBadgeTone.Warning + }, + ) + } +} + +@Composable +private fun ProductDetailPropertyGroupRow( + row: ProductDetailRowUiModel.PropertyGroup, + modifier: Modifier, +) { + val propertyValue = buildString { + row.properties.forEach { property -> + when { + property.label.isEmpty() -> append(property.value) + property.value.isNotEmpty() -> { + if (isNotEmpty()) append('\n') + append(stringResource(row.propertyFormat, property.label, property.value)) + } + } + } + } + val isSingleUntitledValue = row.properties.size == 1 && !row.showTitle + Column { + ProductDetailPropertyCell( + title = if (isSingleUntitledValue) propertyValue else stringResource(row.title), + description = propertyValue.takeUnless { isSingleUntitledValue }?.let(::AnnotatedString), + icon = row.icon, + onClick = row.onClick, + isHighlighted = row.isHighlighted, + modifier = modifier, + ) + ProductDetailOptionalDivider( + show = row.showDivider, + hasLeadingIcon = row.icon != null, + ) + } +} + +@Composable +private fun ProductDetailLinkRow( + row: ProductDetailRowUiModel.Link, + modifier: Modifier, +) { + Column { + WooCell( + title = stringResource(row.title), + onClick = row.onClick, + enabled = row.onClick != null, + modifier = modifier.disabledWhen(row.onClick == null), + leadingContent = row.icon?.let { icon -> { ProductDetailIcon(icon) } }, + trailingContent = row.onClick?.let { { WooCellTrailingAffordance() } }, + ) + ProductDetailOptionalDivider( + show = row.showDivider, + hasLeadingIcon = row.icon != null, + ) + } +} + +@Composable +private fun ProductDetailButtonRow( + row: ProductDetailRowUiModel.Button, + modifier: Modifier, +) { + var showTooltip by rememberSaveable(row.key) { mutableStateOf(row.tooltip != null) } + val onClick by rememberUpdatedState(row.onClick) + val onTooltipDismiss by rememberUpdatedState(row.tooltip?.onDismiss) + + Column { + Column( + modifier = modifier.padding( + start = WooTheme.padding.padding7, + end = WooTheme.padding.padding7, + bottom = WooTheme.padding.padding4, + ), + horizontalAlignment = Alignment.Start, + ) { + Box { + WooFilledTonalButton( + text = stringResource(row.text), + onClick = onClick, + size = WooButtonSize.Small, + leadingIcon = row.icon?.let { icon -> { ProductDetailIcon(icon) } }, + ) + DropdownMenu( + expanded = showTooltip && row.tooltip != null, + onDismissRequest = { showTooltip = false }, + modifier = Modifier + .width(TOOLTIP_WIDTH) + .background(WooTheme.colors.surface.bright), + ) { + row.tooltip?.let { tooltip -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(WooTheme.padding.padding5), + verticalArrangement = Arrangement.spacedBy(WooTheme.spacing.space3), + ) { + Text( + text = stringResource(tooltip.title), + color = WooTheme.colors.surface.onDefault, + style = WooTheme.text.titleMedium.emphasized, + ) + Text( + text = stringResource(tooltip.text), + color = WooTheme.colors.surface.onVariant, + style = WooTheme.text.bodyMedium.regular, + ) + WooFilledTonalButton( + text = stringResource(tooltip.dismissButtonText), + onClick = { + showTooltip = false + onTooltipDismiss?.invoke() + }, + ) + } + } + } + } + row.link?.let { link -> + Spacer(modifier = Modifier.size(WooTheme.spacing.space3)) + Text( + text = productDetailAiAttributionText( + parsedHtml = AnnotatedString.fromHtml(stringResource(link.text)), + onVariantColor = WooTheme.colors.surface.onVariant, + linkColor = WooTheme.colors.primary, + ), + style = WooTheme.text.bodyMedium.emphasized, + modifier = Modifier.clickable(role = Role.Button, onClick = link.onClick), + ) + } + } + ProductDetailOptionalDivider( + show = row.showDivider, + ) + } +} + +@Composable +private fun ProductDetailSwitchRow( + row: ProductDetailRowUiModel.Switch, + modifier: Modifier, +) { + val onStateChanged by rememberUpdatedState(row.onStateChanged) + WooCell( + title = stringResource(row.title), + enabled = row.onStateChanged != null, + onClick = row.onStateChanged?.let { { onStateChanged?.invoke(!row.isOn) } }, + modifier = modifier.disabledWhen(row.onStateChanged == null), + leadingContent = row.icon?.let { icon -> { ProductDetailIcon(icon) } }, + trailingContent = { + WooSwitch( + checked = row.isOn, + onCheckedChange = null, + enabled = row.onStateChanged != null, + ) + }, + ) +} + +@Composable +private fun ProductDetailPropertyCell( + title: String, + description: AnnotatedString?, + icon: Int?, + onClick: (() -> Unit)?, + modifier: Modifier, + maxLines: Int = Int.MAX_VALUE, + isHighlighted: Boolean = false, + additionalContent: (@Composable () -> Unit)? = null, +) { + val interactionSource = remember { MutableInteractionSource() } + val clickableModifier = if (onClick != null) { + modifier.clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + onClick = onClick, + ) + } else { + modifier + } + val foregroundColor = if (isHighlighted) { + WooTheme.colors.status.onWarningContainer + } else { + WooTheme.colors.surface.onDefault + } + + Row( + modifier = clickableModifier + .fillMaxWidth() + .heightIn(min = MIN_ROW_HEIGHT) + .background(WooTheme.colors.surface.bright) + .padding(horizontal = WooTheme.padding.padding7, vertical = WooTheme.padding.padding4), + horizontalArrangement = Arrangement.spacedBy(WooTheme.spacing.space5), + verticalAlignment = Alignment.CenterVertically, + ) { + icon?.let { ProductDetailIcon(it, tint = foregroundColor) } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(WooTheme.spacing.space1), + ) { + Text( + text = title, + color = foregroundColor, + style = WooTheme.text.bodyLarge.emphasized, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + description?.takeIf { it.isNotEmpty() }?.let { + Text( + text = it, + color = if (isHighlighted) foregroundColor else WooTheme.colors.surface.onVariant, + style = WooTheme.text.bodyMedium.regular, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + additionalContent?.invoke() + } + if (onClick != null) { + Icon( + imageVector = WooIcons.Regular.AngleRight, + contentDescription = stringResource(R.string.product_property_edit), + tint = foregroundColor, + modifier = Modifier.size(WooTheme.iconSize.size18), + ) + } + } +} + +@Composable +private fun ProductRating(rating: Float) { + val ratingDescription = stringResource(R.string.product_rating_content_description, rating) + Row(modifier = Modifier.clearAndSetSemantics { contentDescription = ratingDescription }) { + repeat(RATING_STAR_COUNT) { index -> + val fraction = (rating - index).coerceIn(0f, 1f) + Box(modifier = Modifier.size(WooTheme.iconSize.size18)) { + Icon( + imageVector = WooIcons.Regular.Star, + contentDescription = null, + tint = WooTheme.colors.alert.orange, + modifier = Modifier.fillMaxSize(), + ) + if (fraction > 0f) { + Box( + modifier = Modifier + .fillMaxWidth(fraction) + .fillMaxHeight() + .clipToBounds(), + ) { + Icon( + imageVector = WooIcons.Solid.Star, + contentDescription = null, + tint = WooTheme.colors.alert.orange, + modifier = Modifier.size(WooTheme.iconSize.size18), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ProductDetailRatingSummary( + rating: Float, + reviewCount: String, +) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(WooTheme.spacing.space2), + verticalArrangement = Arrangement.spacedBy(WooTheme.spacing.space1), + ) { + ProductRating(rating) + Text( + text = reviewCount, + color = WooTheme.colors.surface.onVariant, + style = WooTheme.text.bodyMedium.regular, + ) + } +} + +private fun titleFieldValue(text: String, moveCursorToEnd: Boolean) = TextFieldValue( + text = text, + selection = if (moveCursorToEnd) TextRange(text.length) else TextRange.Zero, +) + +private fun synchronizeTitleFieldValue( + externalText: String, + currentValue: TextFieldValue, + preserveCurrentValue: Boolean, + moveCursorToEnd: Boolean, +) = if (externalText == currentValue.text || preserveCurrentValue) { + currentValue +} else { + titleFieldValue(externalText, moveCursorToEnd) +} + +internal fun synchronizeTitleFieldState( + externalText: String, + isFocused: Boolean, + shouldFocus: Boolean, + currentState: ProductDetailTitleFieldState, +): ProductDetailTitleFieldState { + val matchesExternalText = externalText == currentState.value.text + val shouldClearEditing = !isFocused && matchesExternalText + return ProductDetailTitleFieldState( + value = synchronizeTitleFieldValue( + externalText = externalText, + currentValue = currentState.value, + preserveCurrentValue = currentState.hasEditedWhileFocused && + (isFocused || currentState.restoreFocus), + moveCursorToEnd = isFocused || shouldFocus, + ), + restoreFocus = currentState.restoreFocus && !shouldClearEditing, + hasEditedWhileFocused = currentState.hasEditedWhileFocused && !shouldClearEditing, + ) +} + +internal data class ProductDetailTitleFieldState( + val value: TextFieldValue, + val restoreFocus: Boolean, + val hasEditedWhileFocused: Boolean, +) + +@Composable +private fun ProductDetailIcon( + icon: Int, + tint: androidx.compose.ui.graphics.Color = WooTheme.colors.surface.onVariant, +) { + Icon( + painter = painterResource(icon), + contentDescription = null, + tint = tint, + modifier = Modifier.size(WooTheme.iconSize.size24), + ) +} + +@Composable +private fun ProductDetailOptionalDivider( + show: Boolean, + hasLeadingIcon: Boolean = false, +) { + if (show) { + val startPadding = WooTheme.padding.padding7 + if (hasLeadingIcon) { + WooTheme.iconSize.size24 + WooTheme.spacing.space5 + } else { + 0.dp + } + WooDivider(modifier = Modifier.padding(start = startPadding)) + } +} + +internal fun productDetailAiAttributionText( + parsedHtml: AnnotatedString, + onVariantColor: androidx.compose.ui.graphics.Color, + linkColor: androidx.compose.ui.graphics.Color, +) = parsedHtml.getLinkAnnotations(start = 0, end = parsedHtml.length).let { linkRanges -> + val textWithoutLinkAnnotations = parsedHtml.flatMapAnnotations { range -> + if (range.item is LinkAnnotation) emptyList() else listOf(range) + } + buildAnnotatedString { + withStyle(SpanStyle(color = onVariantColor)) { + append(textWithoutLinkAnnotations) + } + linkRanges.forEach { range -> + addStyle( + style = SpanStyle( + color = linkColor, + textDecoration = TextDecoration.Underline, + ), + start = range.start, + end = range.end, + ) + } + } +} + +private fun Modifier.disabledWhen(isDisabled: Boolean) = if (isDisabled) { + semantics { disabled() } +} else { + this +} + +private const val RATING_STAR_COUNT = 5 +private val MIN_ROW_HEIGHT = 56.dp +private val MIN_EDITABLE_HEIGHT = 64.dp +private val TOOLTIP_WIDTH = 280.dp diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailScreen.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailScreen.kt new file mode 100644 index 00000000000..2f396bb871a --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailScreen.kt @@ -0,0 +1,349 @@ +package com.woocommerce.android.ui.products.details + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.woocommerce.android.R +import com.woocommerce.android.ui.compose.animations.SkeletonView +import com.woocommerce.android.ui.compose.designsystem.WooTheme +import com.woocommerce.android.ui.compose.designsystem.component.WooBadge +import com.woocommerce.android.ui.compose.designsystem.component.WooBadgeTone +import com.woocommerce.android.ui.compose.designsystem.component.WooCell +import com.woocommerce.android.ui.compose.designsystem.component.WooCellTrailingAffordance +import com.woocommerce.android.ui.compose.designsystem.component.WooDivider +import com.woocommerce.android.ui.compose.designsystem.component.WooFilledTonalButton +import com.woocommerce.android.ui.compose.designsystem.foundation.WooDesignSystemThemeWithBackground +import com.woocommerce.android.ui.compose.designsystem.icons.Plus +import com.woocommerce.android.ui.compose.designsystem.icons.WooIcons +import com.woocommerce.android.ui.compose.designsystem.icons.Xmark +import kotlinx.coroutines.delay + +@Composable +fun ProductDetailScreen( + state: ProductDetailScreenState, + onLinkedProductPromoClicked: () -> Unit, + onLinkedProductPromoDismissed: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxSize(), + color = WooTheme.colors.background.section, + ) { + when (state) { + ProductDetailScreenState.Loading -> ProductDetailLoading() + is ProductDetailScreenState.Empty -> state.message?.let { ProductDetailError(it) } ?: ProductDetailEmpty() + is ProductDetailScreenState.Error -> ProductDetailError(state.message) + is ProductDetailScreenState.Content -> ProductDetailContent( + state = state, + onLinkedProductPromoClicked = onLinkedProductPromoClicked, + onLinkedProductPromoDismissed = onLinkedProductPromoDismissed, + ) + } + } +} + +@Composable +private fun ProductDetailEmpty() { + Box( + modifier = Modifier.fillMaxSize(), + ) +} + +@Composable +private fun ProductDetailContent( + state: ProductDetailScreenState.Content, + onLinkedProductPromoClicked: () -> Unit, + onLinkedProductPromoDismissed: () -> Unit, +) { + val listState = rememberLazyListState() + val nestedScrollInterop = rememberNestedScrollInteropConnection() + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .nestedScroll(nestedScrollInterop) + .testTag(ProductDetailTestTags.LIST), + ) { + if (state.showLinkedProductPromo) { + item(key = LINKED_PROMO_KEY) { + LinkedProductPromo( + onClick = onLinkedProductPromoClicked, + onDismiss = onLinkedProductPromoDismissed, + ) + } + } + state.cards.forEach { card -> + productDetailCard(card) + } + } +} + +@Composable +internal fun ProductDetailFooter( + state: ProductDetailScreenState, + onAddMoreClicked: () -> Unit, +) { + if (state is ProductDetailScreenState.Content && state.showAddMore) { + ProductDetailAddMore(onClick = onAddMoreClicked) + } +} + +private fun LazyListScope.productDetailCard(card: ProductDetailCardUiModel) { + if (card.caption.isNotBlank()) { + item(key = productDetailItemKey(card.key, CARD_CAPTION_KEY)) { + Surface( + color = WooTheme.colors.surface.bright, + modifier = Modifier.fillMaxWidth(), + ) { + Column { + Text( + text = card.caption, + color = WooTheme.colors.surface.onDefault, + style = WooTheme.text.titleMedium.emphasized, + modifier = Modifier.padding( + horizontal = WooTheme.padding.padding7, + vertical = WooTheme.padding.padding4, + ), + ) + WooDivider(modifier = Modifier.padding(start = WooTheme.padding.padding7)) + } + } + } + } + items( + items = card.rows, + key = { row -> productDetailItemKey(card.key, row.key) }, + ) { row -> + Surface( + color = WooTheme.colors.surface.bright, + modifier = Modifier.fillMaxWidth(), + ) { + ProductDetailRow(row) + } + } + item(key = productDetailItemKey(card.key, CARD_SPACER_KEY)) { + Spacer(modifier = Modifier.height(WooTheme.spacing.space3)) + } +} + +internal fun productDetailItemKey(cardKey: String, itemKey: String) = "$cardKey:$itemKey" + +@Composable +private fun ProductDetailAddMore(onClick: () -> Unit) { + Surface( + color = WooTheme.colors.surface.bright, + shadowElevation = ADD_MORE_ELEVATION, + modifier = Modifier.fillMaxWidth(), + ) { + WooCell( + title = stringResource(R.string.product_detail_add_more), + onClick = onClick, + leadingContent = { + Icon( + imageVector = WooIcons.Regular.Plus, + contentDescription = null, + tint = WooTheme.colors.primary, + ) + }, + trailingContent = { WooCellTrailingAffordance() }, + ) + } +} + +@Composable +private fun LinkedProductPromo( + onClick: () -> Unit, + onDismiss: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(WooTheme.padding.padding5), + color = WooTheme.colors.container.secondaryContainer, + contentColor = WooTheme.colors.container.onSecondaryContainer, + shape = androidx.compose.foundation.shape.RoundedCornerShape(WooTheme.radius.large), + ) { + Row( + modifier = Modifier.padding(WooTheme.padding.padding5), + horizontalArrangement = Arrangement.spacedBy(WooTheme.spacing.space4), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(WooTheme.spacing.space3), + ) { + WooBadge(text = stringResource(R.string.tip), tone = WooBadgeTone.Info) + Text( + text = stringResource(R.string.promo_linked_products_banner_title), + style = WooTheme.text.titleMedium.emphasized, + ) + Text( + text = stringResource(R.string.promo_linked_products_banner_message), + style = WooTheme.text.bodyMedium.regular, + ) + WooFilledTonalButton( + text = stringResource(R.string.set_up_now), + onClick = onClick, + ) + } + IconButton(onClick = onDismiss) { + Icon( + imageVector = WooIcons.Regular.Xmark, + contentDescription = stringResource(R.string.dismiss), + ) + } + } + } +} + +@Composable +private fun ProductDetailLoading() { + var isVisible by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + delay(LOADING_DELAY_MS) + isVisible = true + } + + if (isVisible) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(WooTheme.padding.padding7), + verticalArrangement = Arrangement.spacedBy(WooTheme.spacing.space5), + ) { + SkeletonView( + modifier = Modifier + .fillMaxWidth(SKELETON_TITLE_WIDTH) + .height(SKELETON_TITLE_HEIGHT) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(WooTheme.radius.medium)), + ) + repeat(SKELETON_ROW_COUNT) { + SkeletonView( + modifier = Modifier + .fillMaxWidth() + .height(SKELETON_ROW_HEIGHT) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(WooTheme.radius.medium)), + ) + } + } + } +} + +@Composable +private fun ProductDetailError(@androidx.annotation.StringRes message: Int) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(WooTheme.padding.padding7), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + painter = painterResource(R.drawable.img_woo_generic_error), + contentDescription = null, + modifier = Modifier.size(ERROR_IMAGE_SIZE), + ) + Spacer(modifier = Modifier.height(WooTheme.spacing.space6)) + Text( + text = stringResource(message), + color = WooTheme.colors.surface.onDefault, + style = WooTheme.text.titleLarge.emphasized, + ) + } +} + +@PreviewLightDark +@Composable +private fun ProductDetailAddPreview() { + WooDesignSystemThemeWithBackground { + ProductDetailPreview(ProductDetailPreviewData.addProductState) + } +} + +@PreviewLightDark +@Composable +private fun ProductDetailExistingPreview() { + WooDesignSystemThemeWithBackground { + ProductDetailPreview(ProductDetailPreviewData.existingProductState) + } +} + +@PreviewLightDark +@Composable +private fun ProductDetailWarningPreview() { + WooDesignSystemThemeWithBackground { + ProductDetailPreview(ProductDetailPreviewData.warningState) + } +} + +@PreviewLightDark +@Composable +private fun ProductDetailLoadingPreview() { + WooDesignSystemThemeWithBackground { + ProductDetailPreview(ProductDetailScreenState.Loading) + } +} + +@PreviewLightDark +@Composable +private fun ProductDetailErrorPreview() { + WooDesignSystemThemeWithBackground { + ProductDetailPreview(ProductDetailScreenState.Error(R.string.product_detail_fetch_product_error)) + } +} + +@Composable +private fun ProductDetailPreview(state: ProductDetailScreenState) { + Column(modifier = Modifier.fillMaxSize()) { + ProductDetailScreen( + state = state, + onLinkedProductPromoClicked = {}, + onLinkedProductPromoDismissed = {}, + modifier = Modifier.weight(1f), + ) + ProductDetailFooter(state = state, onAddMoreClicked = {}) + } +} + +private const val LINKED_PROMO_KEY = "linked_product_promo" +private const val CARD_CAPTION_KEY = "caption" +private const val CARD_SPACER_KEY = "spacer" +private const val LOADING_DELAY_MS = 250L +private const val SKELETON_ROW_COUNT = 5 +private const val SKELETON_TITLE_WIDTH = 0.55f +private val ADD_MORE_ELEVATION = 4.dp +private val ERROR_IMAGE_SIZE = 160.dp +private val SKELETON_TITLE_HEIGHT = 32.dp +private val SKELETON_ROW_HEIGHT = 72.dp diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailTestTags.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailTestTags.kt new file mode 100644 index 00000000000..e6f83841083 --- /dev/null +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/products/details/ProductDetailTestTags.kt @@ -0,0 +1,8 @@ +package com.woocommerce.android.ui.products.details + +object ProductDetailTestTags { + const val LIST = "productDetailList" + const val TITLE = "editText" + + fun row(key: String) = "productDetailRow_$key" +} diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 846ad4f2787..3b09083faea 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -1973,6 +1973,7 @@ \u2022 one approved review \u2022 no approved reviews Reviews + %1$.1f out of 5 Downloadable files Custom Fields View and edit custom fields diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailAiAttributionTextTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailAiAttributionTextTest.kt new file mode 100644 index 00000000000..6d8f5cf21c5 --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailAiAttributionTextTest.kt @@ -0,0 +1,90 @@ +package com.woocommerce.android.ui.products.details + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ProductDetailAiAttributionTextTest { + @Test + fun `given localized attribution HTML, when styled, then onVariant text precedes primary underlined link`() { + // GIVEN + val parsedHtml = AnnotatedString.fromHtml( + "Powered by AI. Learn more." + ) + + // WHEN + val result = productDetailAiAttributionText( + parsedHtml = parsedHtml, + onVariantColor = ON_VARIANT_COLOR, + linkColor = LINK_COLOR, + ) + + // THEN + val linkStart = result.text.indexOf(LINK_TEXT) + val linkEnd = linkStart + LINK_TEXT.length + val onVariantStyle = result.spanStyles.single { it.item.color == ON_VARIANT_COLOR } + val linkStyle = result.spanStyles.single { it.item.color == LINK_COLOR } + val onVariantStyleIndex = result.spanStyles.indexOf(onVariantStyle) + val linkStyleIndex = result.spanStyles.indexOf(linkStyle) + assertThat(result.text).isEqualTo("Powered by AI. Learn more.") + assertThat(onVariantStyle.start).isZero() + assertThat(onVariantStyle.end).isEqualTo(result.length) + assertThat(linkStyle.start).isEqualTo(linkStart) + assertThat(linkStyle.end).isEqualTo(linkEnd) + assertThat(linkStyle.item.textDecoration).isEqualTo(TextDecoration.Underline) + assertThat(linkStyleIndex).isGreaterThan(onVariantStyleIndex) + assertThat(linkStyle.start).isGreaterThan(0) + assertThat(linkStyle.end).isLessThan(result.length) + } + + @Test + fun `given RTL attribution HTML, when styled, then localized order and link range are preserved`() { + // GIVEN + val prefix = "مدعوم من الذكاء الاصطناعي. " + val link = "تعرّف على المزيد" + val suffix = "." + + // WHEN + val result = productDetailAiAttributionText( + parsedHtml = givenParsedAttribution(prefix, link, suffix), + onVariantColor = ON_VARIANT_COLOR, + linkColor = LINK_COLOR, + ) + + // THEN + val linkStyle = result.spanStyles.single { it.item.color == LINK_COLOR } + assertThat(result.text).isEqualTo(prefix + link + suffix) + assertThat(result.text.substring(linkStyle.start, linkStyle.end)).isEqualTo(link) + assertThat(linkStyle.item.textDecoration).isEqualTo(TextDecoration.Underline) + } + + private fun givenParsedAttribution( + prefix: String, + link: String, + suffix: String, + ): AnnotatedString = buildAnnotatedString { + append(prefix) + pushLink(LinkAnnotation.Url(url = "learn-more")) + withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { + append(link) + } + pop() + append(suffix) + } + + private companion object { + const val LINK_TEXT = "Learn more" + val ON_VARIANT_COLOR = Color(0x991E1E1E) + val LINK_COLOR = Color(0xFF720EEC) + } +} diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailTitleFieldStateTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailTitleFieldStateTest.kt new file mode 100644 index 00000000000..3981ef8842c --- /dev/null +++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/products/details/ProductDetailTitleFieldStateTest.kt @@ -0,0 +1,104 @@ +package com.woocommerce.android.ui.products.details + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class ProductDetailTitleFieldStateTest { + @Test + fun `given the title is unfocused, when the external text changes, then the field is updated`() { + // GIVEN + val currentState = givenTitleFieldState(text = ORIGINAL_TITLE) + + // WHEN + val result = synchronizeTitleFieldState( + externalText = UPDATED_TITLE, + isFocused = false, + shouldFocus = false, + currentState = currentState, + ) + + // THEN + assertThat(result.value).isEqualTo(TextFieldValue(UPDATED_TITLE, TextRange.Zero)) + } + + @Test + fun `given an in-progress focused edit, when the external text changes, then the edit is preserved`() { + // GIVEN + val currentState = givenTitleFieldState( + text = EDITED_TITLE, + selection = EDIT_SELECTION, + restoreFocus = true, + hasEditedWhileFocused = true, + ) + + // WHEN + val result = synchronizeTitleFieldState( + externalText = UPDATED_TITLE, + isFocused = true, + shouldFocus = false, + currentState = currentState, + ) + + // THEN + assertThat(result).isEqualTo(currentState) + } + + @Test + fun `given focus should be restored after recreation, when synchronized, then the edit and cursor are preserved`() { + // GIVEN + val currentState = givenTitleFieldState( + text = EDITED_TITLE, + selection = EDIT_SELECTION, + restoreFocus = true, + hasEditedWhileFocused = true, + ) + + // WHEN + val result = synchronizeTitleFieldState( + externalText = UPDATED_TITLE, + isFocused = false, + shouldFocus = false, + currentState = currentState, + ) + + // THEN + assertThat(result).isEqualTo(currentState) + } + + @Test + fun `given the title should focus, when the external text changes, then the cursor moves to the end`() { + // GIVEN + val currentState = givenTitleFieldState(text = ORIGINAL_TITLE) + + // WHEN + val result = synchronizeTitleFieldState( + externalText = UPDATED_TITLE, + isFocused = false, + shouldFocus = true, + currentState = currentState, + ) + + // THEN + assertThat(result.value).isEqualTo(TextFieldValue(UPDATED_TITLE, TextRange(UPDATED_TITLE.length))) + } + + private fun givenTitleFieldState( + text: String, + selection: TextRange = TextRange.Zero, + restoreFocus: Boolean = false, + hasEditedWhileFocused: Boolean = false, + ) = ProductDetailTitleFieldState( + value = TextFieldValue(text, selection), + restoreFocus = restoreFocus, + hasEditedWhileFocused = hasEditedWhileFocused, + ) + + private companion object { + const val ORIGINAL_TITLE = "Original title" + const val UPDATED_TITLE = "Updated title" + const val EDITED_TITLE = "Edited title" + val EDIT_SELECTION = TextRange(2, 7) + } +}