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)
+ }
+}