🤖 [Dynamic Cards] Ordering logic

Fixes #19739

## Description
This PR builds the UI models in the right order/position from the API response.

Note: I added a separate issue for the remote feature flag display logic to avoid extending this PR more https://github.com/wordpress-mobile/WordPress-Android/issues/19797

-----

## To Test:
- Apply this patch: [cardmockspatch.patch](https://github.com/wordpress-mobile/WordPress-Android/files/13678298/cardmockspatch.patch)
- Run the app and confirm that you can see two dynamic cards on the top and bottom of the dashboard.
### Alternatively
- Add a break point [here](https://github.com/wordpress-mobile/WordPress-Android/blob/trunk/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/CardsBuilder.kt#L37)
- Run and verify that the expected `Dynamic` objects are listed

-----

## Regression Notes

1. Potential unintended areas of impact

    - My site dashboard

2. What I did to test those areas of impact (or what existing automated tests I relied on)

    - Manual testing

3. What automated tests I added (or what prevented me from doing so)

    - Added unit tests and adapted existing

-----

## PR Submission Checklist:

- [x] I have completed the Regression Notes.
- [x] I have considered adding accessibility improvements for my changes.
- [x] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.

-----

## UI Changes Testing Checklist:

- [ ] Portrait and landscape orientations.
- [ ] Light and dark modes.
- [ ] Fonts: Larger, smaller and bold text.
- [ ] High contrast.
- [ ] Talkback.
- [ ] Languages with large words or with letters/accents not frequently used in English.
- [ ] Right-to-left languages. (Even if translation isn’t complete, formatting should still respect the right-to-left layout)
- [ ] Large and small screen sizes. (Tablet and smaller phones)
- [ ] Multi-tasking: Split screen and Pop-up view. (Android 10 or higher)

🤖 [Dynamic Cards] Ordering logic

🤖 [Dynamic Cards] Ordering logic
Order type `top` cards will be sorted to the beginning of the card list.
Order type `bottom` cards will be sorted to the end of the card list.

./gradlew :WordPress:testWordpressVanillaDebugUnitTest --tests "org.wordpress.android.ui.mysite.cards.dynamiccard.DynamicCardsBuilderTest" --tests "org.wordpress.android.ui.mysite.MySiteViewModelTest" --tests "org.wordpress.android.ui.mysite.cards.dashboard.CardsBuilderTest" --tests "org.wordpress.android.ui.mysite.cards.dynamiccard.DynamicCardsViewModelSliceTest" --tests "org.wordpress.android.ui.mysite.cards.CardsBuilderTest"

diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteAdapterDiffCallback.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteAdapterDiffCallback.kt
index 207d40c6055b..6f4e178089c9 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteAdapterDiffCallback.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteAdapterDiffCallback.kt
@@ -8,6 +8,7 @@ import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.BloganuaryNudgeCar
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.BloggingPromptCard.BloggingPromptCardWithData
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.DashboardPlansCard
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.DomainRegistrationCard
+import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.Dynamic
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.ErrorCard
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.ErrorWithinCard
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.JetpackFeatureCard
@@ -66,6 +67,9 @@ object MySiteAdapterDiffCallback : DiffUtil.ItemCallback<MySiteCardAndItem>() {
                     MySiteCardAndItem.Card.NoCardsMessage -> true
             oldItem is PersonalizeCardModel && updatedItem is PersonalizeCardModel -> true
             oldItem is WpSotw2023NudgeCardModel && updatedItem is WpSotw2023NudgeCardModel -> true
+            oldItem is Dynamic && updatedItem is Dynamic -> {
+                oldItem.id == updatedItem.id
+            }
             else -> throw UnsupportedOperationException("Diff not implemented yet")
         }
     }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteCardAndItemBuilderParams.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteCardAndItemBuilderParams.kt
index 0b78ee06dee8..ec761509ef6c 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteCardAndItemBuilderParams.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteCardAndItemBuilderParams.kt
@@ -6,6 +6,7 @@ import org.wordpress.android.fluxc.model.SiteModel
 import org.wordpress.android.fluxc.model.blaze.BlazeCampaignModel
 import org.wordpress.android.fluxc.model.bloggingprompts.BloggingPromptModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.PagesCardModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.PostsCardModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.TodaysStatsCardModel
@@ -68,8 +69,9 @@ sealed class MySiteCardAndItemBuilderParams {
         val blazeCardBuilderParams: BlazeCardBuilderParams? = null,
         val dashboardCardPlansBuilderParams: DashboardCardPlansBuilderParams,
         val pagesCardBuilderParams: PagesCardBuilderParams,
-        val activityCardBuilderParams: ActivityCardBuilderParams
-    ) : MySiteCardAndItemBuilderParams()
+        val activityCardBuilderParams: ActivityCardBuilderParams,
+        val dynamicCardsBuilderParams: DynamicCardsBuilderParams,
+        ) : MySiteCardAndItemBuilderParams()

     data class TodaysStatsCardBuilderParams(
         val todaysStatsCard: TodaysStatsCardModel?,
@@ -158,6 +160,13 @@ sealed class MySiteCardAndItemBuilderParams {
         val onHideMenuItemClick: () -> Unit
     ) : MySiteCardAndItemBuilderParams()

+    data class DynamicCardsBuilderParams(
+        val dynamicCards: DynamicCardsModel?,
+        val onActionClick: (actionUrl: String) -> Unit,
+        val onMoreMenuClick: (cardId: String) -> Unit,
+        val onHideMenuItemClick: (cardId: String) -> Unit
+    ) : MySiteCardAndItemBuilderParams()
+
     sealed class BlazeCardBuilderParams : MySiteCardAndItemBuilderParams() {
         data class PromoteWithBlazeCardBuilderParams(
             val onClick: () -> Unit,
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt
index c498ecca501c..68af2a267ddf 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/MySiteViewModel.kt
@@ -20,6 +20,7 @@ import org.wordpress.android.R
 import org.wordpress.android.analytics.AnalyticsTracker.Stat
 import org.wordpress.android.fluxc.Dispatcher
 import org.wordpress.android.fluxc.model.SiteModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.ActivityCardModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.PagesCardModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.PostsCardModel
@@ -68,6 +69,7 @@ import org.wordpress.android.ui.mysite.cards.dashboard.pages.PagesCardViewModelS
 import org.wordpress.android.ui.mysite.cards.dashboard.plans.PlansCardUtils
 import org.wordpress.android.ui.mysite.cards.dashboard.posts.PostsCardViewModelSlice
 import org.wordpress.android.ui.mysite.cards.dashboard.todaysstats.TodaysStatsViewModelSlice
+import org.wordpress.android.ui.mysite.cards.dynamiccard.DynamicCardsViewModelSlice
 import org.wordpress.android.ui.mysite.cards.jetpackfeature.JetpackFeatureCardHelper
 import org.wordpress.android.ui.mysite.cards.jetpackfeature.JetpackFeatureCardShownTracker
 import org.wordpress.android.ui.mysite.cards.jpfullplugininstall.JetpackInstallFullPluginCardBuilder
@@ -155,6 +157,7 @@ class MySiteViewModel @Inject constructor(
     private val wpJetpackIndividualPluginHelper: WPJetpackIndividualPluginHelper,
     private val blazeCardViewModelSlice: BlazeCardViewModelSlice,
     private val pagesCardViewModelSlice: PagesCardViewModelSlice,
+    private val dynamicCardsViewModelSlice: DynamicCardsViewModelSlice,
     private val todaysStatsViewModelSlice: TodaysStatsViewModelSlice,
     private val postsCardViewModelSlice: PostsCardViewModelSlice,
     private val activityLogCardViewModelSlice: ActivityLogCardViewModelSlice,
@@ -497,6 +500,9 @@ class MySiteViewModel @Inject constructor(
                 activityCardBuilderParams = activityLogCardViewModelSlice.getActivityLogCardBuilderParams(
                     cardsUpdate?.cards?.firstOrNull { it is ActivityCardModel } as? ActivityCardModel
                 ),
+                dynamicCardsBuilderParams = dynamicCardsViewModelSlice.getBuilderParams(
+                    cardsUpdate?.cards?.firstOrNull { it is DynamicCardsModel } as? DynamicCardsModel
+                )
             ),
             jetpackInstallFullPluginCardParams
         )
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilder.kt
index 98fa711083d4..9cb9e0d59c5e 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilder.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilder.kt
@@ -1,5 +1,6 @@
 package org.wordpress.android.ui.mysite.cards.dashboard

+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.CardOrder
 import org.wordpress.android.ui.mysite.MySiteCardAndItem
 import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.DashboardCardsBuilderParams
 import org.wordpress.android.ui.mysite.cards.blaze.BlazeCardBuilder
@@ -10,6 +11,7 @@ import org.wordpress.android.ui.mysite.cards.dashboard.pages.PagesCardBuilder
 import org.wordpress.android.ui.mysite.cards.dashboard.posts.PostCardBuilder
 import org.wordpress.android.ui.mysite.cards.dashboard.todaysstats.TodaysStatsCardBuilder
 import org.wordpress.android.ui.mysite.cards.dashboard.plans.PlansCardBuilder
+import org.wordpress.android.ui.mysite.cards.dynamiccard.DynamicCardsBuilder
 import org.wordpress.android.ui.utils.ListItemInteraction
 import javax.inject.Inject

@@ -21,7 +23,8 @@ class CardsBuilder @Inject constructor(
     private val blazeCardBuilder: BlazeCardBuilder,
     private val plansCardBuilder: PlansCardBuilder,
     private val pagesCardBuilder: PagesCardBuilder,
-    private val activityCardBuilder: ActivityCardBuilder
+    private val activityCardBuilder: ActivityCardBuilder,
+    private val dynamicCardsBuilder: DynamicCardsBuilder,
 ) {
     fun build(
         dashboardCardsBuilderParams: DashboardCardsBuilderParams
@@ -29,6 +32,9 @@ class CardsBuilder @Inject constructor(
         if (dashboardCardsBuilderParams.showErrorCard) {
             add(createErrorCard(dashboardCardsBuilderParams.onErrorRetryClick))
         } else {
+            dynamicCardsBuilder.build(dashboardCardsBuilderParams.dynamicCardsBuilderParams, CardOrder.TOP)
+                ?.let { addAll(it) }
+
             bloganuaryNudgeCardBuilder.build(dashboardCardsBuilderParams.bloganuaryNudgeCardBuilderParams)
                 ?.let { add(it) }

@@ -51,6 +57,9 @@ class CardsBuilder @Inject constructor(
             pagesCardBuilder.build(dashboardCardsBuilderParams.pagesCardBuilderParams)?.let { add(it) }

             activityCardBuilder.build(dashboardCardsBuilderParams.activityCardBuilderParams)?.let { add(it) }
+
+            dynamicCardsBuilder.build(dashboardCardsBuilderParams.dynamicCardsBuilderParams, CardOrder.BOTTOM)
+                ?.let { addAll(it) }
         }
     }.toList()

diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilder.kt
new file mode 100644
index 000000000000..23712cf6bbd0
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilder.kt
@@ -0,0 +1,73 @@
+package org.wordpress.android.ui.mysite.cards.dynamiccard
+
+import org.wordpress.android.fluxc.model.dashboard.CardModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.CardOrder
+import org.wordpress.android.ui.deeplinks.handlers.DeepLinkHandlers
+import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.Dynamic
+import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams
+import org.wordpress.android.ui.utils.ListItemInteraction.Companion.create
+import org.wordpress.android.util.UriWrapper
+import org.wordpress.android.util.UrlUtilsWrapper
+import javax.inject.Inject
+
+class DynamicCardsBuilder @Inject constructor(
+    private val urlUtils: UrlUtilsWrapper,
+    private val deepLinkHandlers: DeepLinkHandlers
+) {
+    fun build(params: DynamicCardsBuilderParams, order: CardOrder): List<Dynamic>? {
+        if (!shouldBuildCard(params, order)) {
+            return null
+        }
+        return convertToDynamicCards(params, order)
+    }
+
+    private fun shouldBuildCard(params: DynamicCardsBuilderParams, order: CardOrder): Boolean {
+        if (params.dynamicCards == null || params.dynamicCards.pages.none { it.order == order }) return false
+        return true
+    }
+
+    private fun convertToDynamicCards(params: DynamicCardsBuilderParams, order: CardOrder): List<Dynamic> {
+        val cards = params.dynamicCards?.pages?.filter { it.order == order } ?: emptyList()
+        return cards.map { card ->
+            Dynamic(
+                id = card.id,
+                rows = card.rows.map { row ->
+                    Dynamic.Row(
+                        iconUrl = row.icon,
+                        title = row.title,
+                        description = row.description,
+                    )
+                },
+                title = card.title,
+                image = card.featuredImage,
+                action = getActionSource(params, card),
+                onHideMenuItemClick = create(card.id, params.onHideMenuItemClick),
+            )
+        }
+    }
+
+    private fun getActionSource(
+        params: DynamicCardsBuilderParams,
+        card: CardModel.DynamicCardsModel.DynamicCardModel
+    ): Dynamic.ActionSource? = when {
+        isValidUrlOrDeeplink(card.url) && isValidActionTitle(card.action) -> Dynamic.ActionSource.Button(
+            requireNotNull(card.url),
+            create(requireNotNull(card.url), params.onActionClick),
+            requireNotNull(card.action)
+        )
+        isValidUrlOrDeeplink(card.url) -> Dynamic.ActionSource.Card(
+            requireNotNull(card.url),
+            create(requireNotNull(card.url), params.onActionClick)
+        )
+        else -> null
+    }
+
+    private fun isValidUrlOrDeeplink(url: String?): Boolean {
+        return !url.isNullOrEmpty() && (urlUtils.isValidUrlAndHostNotNull(url)
+                || deepLinkHandlers.buildNavigateAction(UriWrapper(url)) != null)
+    }
+
+    private fun isValidActionTitle(title: String?): Boolean {
+        return !title.isNullOrEmpty()
+    }
+}
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsViewModelSlice.kt
new file mode 100644
index 000000000000..8def20febd51
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsViewModelSlice.kt
@@ -0,0 +1,41 @@
+package org.wordpress.android.ui.mysite.cards.dynamiccard
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import org.wordpress.android.fluxc.model.dashboard.CardModel
+import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams
+import org.wordpress.android.ui.mysite.SiteNavigationAction
+import org.wordpress.android.util.AppLog
+import org.wordpress.android.viewmodel.Event
+import javax.inject.Inject
+
+class DynamicCardsViewModelSlice @Inject constructor() {
+    private val _onNavigation = MutableLiveData<Event<SiteNavigationAction>>()
+    val onNavigation = _onNavigation as LiveData<Event<SiteNavigationAction>>
+
+    private val _refresh = MutableLiveData<Event<Boolean>>()
+    val refresh = _refresh as LiveData<Event<Boolean>>
+    fun getBuilderParams(dynamicCards: CardModel.DynamicCardsModel?): DynamicCardsBuilderParams {
+        return DynamicCardsBuilderParams(
+            dynamicCards = dynamicCards,
+            onActionClick = this::onActionClick,
+            onMoreMenuClick = this::onMoreMenuClick,
+            onHideMenuItemClick = this::onHideMenuItemClick
+        )
+    }
+
+    private fun onActionClick(actionUrl: String) {
+        AppLog.d(AppLog.T.MY_SITE_DASHBOARD, "Dynamic dashboard card action clicked: $actionUrl")
+        // TODO
+    }
+
+    private fun onMoreMenuClick(cardId: String) {
+        AppLog.d(AppLog.T.MY_SITE_DASHBOARD, "Dynamic dashboard card action more menu click: $cardId")
+        // TODO
+    }
+
+    private fun onHideMenuItemClick(cardId: String) {
+        AppLog.d(AppLog.T.MY_SITE_DASHBOARD, "Dynamic dashboard card action hide menu click: $cardId")
+        // TODO
+    }
+}

diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt
index 1610b066b8f0..4ebeb51b285e 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/MySiteViewModelTest.kt
@@ -83,6 +83,7 @@ import org.wordpress.android.ui.mysite.cards.dashboard.pages.PagesCardViewModelS
 import org.wordpress.android.ui.mysite.cards.dashboard.plans.PlansCardUtils
 import org.wordpress.android.ui.mysite.cards.dashboard.posts.PostsCardViewModelSlice
 import org.wordpress.android.ui.mysite.cards.dashboard.todaysstats.TodaysStatsViewModelSlice
+import org.wordpress.android.ui.mysite.cards.dynamiccard.DynamicCardsViewModelSlice
 import org.wordpress.android.ui.mysite.cards.jetpackfeature.JetpackFeatureCardHelper
 import org.wordpress.android.ui.mysite.cards.jetpackfeature.JetpackFeatureCardShownTracker
 import org.wordpress.android.ui.mysite.cards.jpfullplugininstall.JetpackInstallFullPluginCardBuilder
@@ -282,6 +283,10 @@ class MySiteViewModelTest : BaseUnitTest() {
     @Mock
     lateinit var wpSotw2023NudgeCardViewModelSlice: WpSotw2023NudgeCardViewModelSlice

+    @Mock
+    lateinit var dynamicCardsViewModelSlice: DynamicCardsViewModelSlice
+
+
     private lateinit var viewModel: MySiteViewModel
     private lateinit var uiModels: MutableList<MySiteViewModel.State>
     private lateinit var snackbars: MutableList<SnackbarMessageHolder>
@@ -416,6 +421,14 @@ class MySiteViewModelTest : BaseUnitTest() {
         whenever(activityLogCardViewModelSlice.getActivityLogCardBuilderParams(anyOrNull())).thenReturn(mock())
         whenever(personalizeCardViewModelSlice.getBuilderParams()).thenReturn(mock())
         whenever(personalizeCardBuilder.build(any())).thenReturn(mock())
+        whenever(dynamicCardsViewModelSlice.getBuilderParams(anyOrNull())).thenReturn(
+            MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+                mock(),
+                mock(),
+                mock(),
+                mock()
+            )
+        )
         whenever(bloganuaryNudgeViewModelSlice.getBuilderParams()).thenReturn(mock())
         whenever(bloggingPromptCardViewModelSlice.getBuilderParams(anyOrNull())).thenReturn(mock())
         whenever(quickLinksItemViewModelSlice.uiState).thenReturn(mock())
@@ -462,6 +475,7 @@ class MySiteViewModelTest : BaseUnitTest() {
             wpJetpackIndividualPluginHelper,
             blazeCardViewModelSlice,
             pagesCardViewModelSlice,
+            dynamicCardsViewModelSlice,
             todaysStatsViewModelSlice,
             postsCardViewModelSlice,
             activityLogCardViewModelSlice,
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/CardsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/CardsBuilderTest.kt
index 304b3bf76228..2ad7f5068215 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/CardsBuilderTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/CardsBuilderTest.kt
@@ -16,6 +16,7 @@ import org.wordpress.android.ui.mysite.MySiteCardAndItem
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.DomainRegistrationCard
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.QuickStartCard
 import org.wordpress.android.ui.mysite.MySiteCardAndItem.Card.QuickStartCard.QuickStartTaskTypeItem
+import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams
 import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.ActivityCardBuilderParams
 import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.BlazeCardBuilderParams
 import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams.BloganuaryNudgeCardBuilderParams
@@ -156,7 +157,8 @@ class CardsBuilderTest {
                     mock()
                 ),
                 pagesCardBuilderParams = PagesCardBuilderParams(mock(), mock(), mock(), mock()),
-                activityCardBuilderParams = ActivityCardBuilderParams(mock(), mock(), mock(), mock(), mock())
+                activityCardBuilderParams = ActivityCardBuilderParams(mock(), mock(), mock(), mock(), mock()),
+                dynamicCardsBuilderParams = DynamicCardsBuilderParams(mock(), mock(), mock(), mock()),
             ),
             jetpackInstallFullPluginCardBuilderParams = JetpackInstallFullPluginCardBuilderParams(
                 site = site,
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilderTest.kt
index cb26f3fdf805..c97fd4f584e6 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilderTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsBuilderTest.kt
@@ -40,6 +40,7 @@ import org.wordpress.android.ui.mysite.cards.dashboard.posts.PostCardBuilder
 import org.wordpress.android.ui.mysite.cards.dashboard.posts.PostCardType.DRAFT
 import org.wordpress.android.ui.mysite.cards.dashboard.todaysstats.TodaysStatsCardBuilder
 import org.wordpress.android.ui.mysite.cards.dashboard.plans.PlansCardBuilder
+import org.wordpress.android.ui.mysite.cards.dynamiccard.DynamicCardsBuilder
 import org.wordpress.android.ui.utils.UiString.UiStringText

 @ExperimentalCoroutinesApi
@@ -69,6 +70,9 @@ class CardsBuilderTest : BaseUnitTest() {
     @Mock
     lateinit var bloganuaryCardBuilder: BloganuaryNudgeCardBuilder

+    @Mock
+    lateinit var dynamicCardsBuilder: DynamicCardsBuilder
+
     private lateinit var cardsBuilder: CardsBuilder

     @Before
@@ -81,7 +85,8 @@ class CardsBuilderTest : BaseUnitTest() {
             blazeCardBuilder,
             dashboardPlansCardBuilder,
             pagesCardBuilder,
-            activityCardBuilder
+            activityCardBuilder,
+            dynamicCardsBuilder
         )
     }

@@ -352,6 +357,12 @@ class CardsBuilderTest : BaseUnitTest() {
                     mock(),
                     mock(),
                     mock()
+                ),
+                dynamicCardsBuilderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+                    mock(),
+                    mock(),
+                    mock(),
+                    mock()
                 )
             )
         )
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilderTest.kt
new file mode 100644
index 000000000000..dfe4f19c3703
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsBuilderTest.kt
@@ -0,0 +1,212 @@
+package org.wordpress.android.ui.mysite.cards.dynamiccard
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.whenever
+import org.wordpress.android.BaseUnitTest
+import org.wordpress.android.fluxc.model.dashboard.CardModel
+import org.wordpress.android.ui.deeplinks.handlers.DeepLinkHandlers
+import org.wordpress.android.ui.mysite.MySiteCardAndItem
+import org.wordpress.android.ui.mysite.MySiteCardAndItemBuilderParams
+import org.wordpress.android.util.UrlUtilsWrapper
+import kotlin.test.assertEquals
+
+/* DYNAMIC CARDS */
+private const val DYNAMIC_CARD_ID = "year_in_review_2023"
+private const val DYNAMIC_CARD_TITLE = "News"
+private const val DYNAMIC_CARD_REMOTE_FEATURE_FLAG = "dynamic_dashboard_cards"
+private const val DYNAMIC_CARD_FEATURED_IMAGE = "https://path/to/image"
+private const val DYNAMIC_CARD_URL = "https://wordpress.com"
+private const val DYNAMIC_CARD_ACTION = "Call to action"
+private const val DYNAMIC_CARD_ORDER = "top"
+private const val DYNAMIC_CARD_ROW_ICON = "https://path/to/image"
+private const val DYNAMIC_CARD_ROW_TITLE = "Row title"
+private const val DYNAMIC_CARD_ROW_DESCRIPTION = "Row description"
+
+private val DYNAMIC_CARD_ROW_MODEL = CardModel.DynamicCardsModel.DynamicCardRowModel(
+    icon = DYNAMIC_CARD_ROW_ICON,
+    title = DYNAMIC_CARD_ROW_TITLE,
+    description = DYNAMIC_CARD_ROW_DESCRIPTION
+)
+
+private val DYNAMIC_CARD_MODEL = CardModel.DynamicCardsModel.DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = DYNAMIC_CARD_URL,
+    action = DYNAMIC_CARD_ACTION,
+    order = CardModel.DynamicCardsModel.CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+private val DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE = CardModel.DynamicCardsModel.DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = DYNAMIC_CARD_URL,
+    action = null,
+    order = CardModel.DynamicCardsModel.CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+private val DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE_AND_URL = CardModel.DynamicCardsModel.DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = "",
+    action = null,
+    order = CardModel.DynamicCardsModel.CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+private val DYNAMIC_CARD_MODEL_INVALID_URL = CardModel.DynamicCardsModel.DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = null,
+    action = DYNAMIC_CARD_ACTION,
+    order = CardModel.DynamicCardsModel.CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+@ExperimentalCoroutinesApi
+class DynamicCardsBuilderTest : BaseUnitTest() {
+    private lateinit var dynamicCardsBuilder: DynamicCardsBuilder
+
+    @Mock
+    private lateinit var urlUtils: UrlUtilsWrapper
+
+    @Mock
+    private lateinit var deepLinkHandlers: DeepLinkHandlers
+
+    @Before
+    fun setUp() {
+        dynamicCardsBuilder = DynamicCardsBuilder(urlUtils, deepLinkHandlers)
+        whenever(urlUtils.isValidUrlAndHostNotNull(DYNAMIC_CARD_URL)).thenReturn(true)
+    }
+
+    @Test
+    fun testBuild() {
+        val expectedCards = listOf(
+            MySiteCardAndItem.Card.Dynamic(
+                id = DYNAMIC_CARD_ID,
+                rows = listOf(
+                    MySiteCardAndItem.Card.Dynamic.Row(
+                        iconUrl = DYNAMIC_CARD_ROW_ICON,
+                        title = DYNAMIC_CARD_ROW_TITLE,
+                        description = DYNAMIC_CARD_ROW_DESCRIPTION,
+                    )
+                ),
+                title = DYNAMIC_CARD_TITLE,
+                image = DYNAMIC_CARD_FEATURED_IMAGE,
+                action = MySiteCardAndItem.Card.Dynamic.ActionSource.Button(
+                    url = DYNAMIC_CARD_URL,
+                    onCtaClick = mock(),
+                    title = DYNAMIC_CARD_ACTION
+                ),
+                onHideMenuItemClick = mock(),
+            )
+        )
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = CardModel.DynamicCardsModel(
+                pages = listOf(DYNAMIC_CARD_MODEL)
+            ),
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
+        assertThat(requireNotNull(dynamicCards).size).isEqualTo(1)
+        assertEquals(expectedCards[0].id, dynamicCards[0].id)
+        assertEquals(expectedCards[0].title, dynamicCards[0].title)
+        assertEquals(expectedCards[0].image, dynamicCards[0].image)
+        assertThat(expectedCards[0].rows.size).isEqualTo(1)
+        assertEquals(expectedCards[0].rows, dynamicCards[0].rows)
+        assertThat(dynamicCards[0].action).isInstanceOf(MySiteCardAndItem.Card.Dynamic.ActionSource.Button::class.java)
+        val expected = expectedCards[0].action as? MySiteCardAndItem.Card.Dynamic.ActionSource.Button
+        val actual = dynamicCards[0].action as? MySiteCardAndItem.Card.Dynamic.ActionSource.Button
+        assertEquals(expected?.title, actual?.title)
+        assertEquals(expected?.url, actual?.url)
+    }
+
+    @Test
+    fun testBuildWithInvalidActionTitle() {
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = CardModel.DynamicCardsModel(
+                pages = listOf(DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE)
+            ),
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
+        assertThat(requireNotNull(dynamicCards).size).isEqualTo(1)
+        assertThat(dynamicCards[0].action).isInstanceOf(MySiteCardAndItem.Card.Dynamic.ActionSource.Card::class.java)
+        val actual = dynamicCards[0].action as? MySiteCardAndItem.Card.Dynamic.ActionSource.Card
+        assertEquals(DYNAMIC_CARD_URL, actual?.url)
+    }
+
+    @Test
+    fun testBuildWithInvalidActionTitleAndUrl() {
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = CardModel.DynamicCardsModel(
+                pages = listOf(DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE_AND_URL)
+            ),
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
+        assertThat(requireNotNull(dynamicCards).size).isEqualTo(1)
+        assertThat(dynamicCards[0].action).isNull()
+    }
+
+    @Test
+    fun testBuildWithInvalidUrl() {
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = CardModel.DynamicCardsModel(
+                pages = listOf(DYNAMIC_CARD_MODEL_INVALID_URL)
+            ),
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
+        assertThat(requireNotNull(dynamicCards).size).isEqualTo(1)
+        assertThat(dynamicCards[0].action).isNull()
+    }
+
+    @Test
+    fun testBuildWithEmptyPosition() {
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = CardModel.DynamicCardsModel(
+                pages = listOf(DYNAMIC_CARD_MODEL_INVALID_ACTION_TITLE_AND_URL)
+            ),
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.BOTTOM)
+        assertThat(dynamicCards).isNull()
+    }
+
+    @Test
+    fun testBuildWithInvalidParams() {
+        val builderParams = MySiteCardAndItemBuilderParams.DynamicCardsBuilderParams(
+            dynamicCards = null,
+            onActionClick = mock(),
+            onMoreMenuClick = mock(),
+            onHideMenuItemClick = mock(),
+        )
+        val dynamicCards = dynamicCardsBuilder.build(builderParams, CardModel.DynamicCardsModel.CardOrder.TOP)
+        assertThat(dynamicCards).isNull()
+    }
+}
diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsViewModelSliceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsViewModelSliceTest.kt
new file mode 100644
index 000000000000..8ea8158bc555
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dynamiccard/DynamicCardsViewModelSliceTest.kt
@@ -0,0 +1,59 @@
+package org.wordpress.android.ui.mysite.cards.dynamiccard
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import org.assertj.core.api.Assertions.assertThat
+import org.junit.Before
+import org.junit.Test
+import org.wordpress.android.BaseUnitTest
+import org.wordpress.android.fluxc.model.dashboard.CardModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.DynamicCardRowModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.DynamicCardModel
+
+/* DYNAMIC CARDS */
+private const val DYNAMIC_CARD_ID = "year_in_review_2023"
+private const val DYNAMIC_CARD_TITLE = "News"
+private const val DYNAMIC_CARD_REMOTE_FEATURE_FLAG = "dynamic_dashboard_cards"
+private const val DYNAMIC_CARD_FEATURED_IMAGE = "https://path/to/image"
+private const val DYNAMIC_CARD_URL = "https://wordpress.com"
+private const val DYNAMIC_CARD_ACTION = "Call to action"
+private const val DYNAMIC_CARD_ORDER = "top"
+private const val DYNAMIC_CARD_ROW_ICON = "https://path/to/image"
+private const val DYNAMIC_CARD_ROW_TITLE = "Row title"
+private const val DYNAMIC_CARD_ROW_DESCRIPTION = "Row description"
+
+private val DYNAMIC_CARD_ROW_MODEL = DynamicCardRowModel(
+    icon = DYNAMIC_CARD_ROW_ICON,
+    title = DYNAMIC_CARD_ROW_TITLE,
+    description = DYNAMIC_CARD_ROW_DESCRIPTION
+)
+
+private val DYNAMIC_CARD_MODEL = DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = DYNAMIC_CARD_URL,
+    action = DYNAMIC_CARD_ACTION,
+    order = CardModel.DynamicCardsModel.CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+private val DYNAMIC_CARDS_MODEL = CardModel.DynamicCardsModel(
+    pages = listOf(DYNAMIC_CARD_MODEL)
+)
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class DynamicCardsViewModelSliceTest : BaseUnitTest() {
+    lateinit var dynamicCardsViewModelSlice: DynamicCardsViewModelSlice
+
+    @Before
+    fun setUp() {
+        dynamicCardsViewModelSlice = DynamicCardsViewModelSlice()
+    }
+
+    @Test
+    fun testGetBuilderParams() {
+        val builderParams = dynamicCardsViewModelSlice.getBuilderParams(DYNAMIC_CARDS_MODEL)
+        assertThat(builderParams.dynamicCards).isEqualTo(DYNAMIC_CARDS_MODEL)
+    }
+}


Fixes #19734

-----

## Description
This PR adds Dynamic Cards support in the dashboard/cards-data Endpoint Response.

Note:
* Adding the `Not Ready for Merge` status till we test with the backend, merge the accompanying FluxC PR and update the reference to point to `trunk`

## To Test:
* Enable https://github.com/wordpress-mobile/WordPress-Android/pull/19765
* Verify that the existing dashboard cards appear as expected
* TODO: Test with a sandbox

-----

## Regression Notes

1. Potential unintended areas of impact

Jetpack app dashboard

2. What I did to test those areas of impact (or what existing automated tests I relied on)

 Manual testing

3. What automated tests I added (or what prevented me from doing so)

 Added new test case

-----

## PR Submission Checklist:

- [x] I have completed the Regression Notes.
- [x] I have considered adding accessibility improvements for my changes.
- [x] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.

-----

## UI Changes Testing Checklist:

- [ ] Portrait and landscape orientations.
- [ ] Light and dark modes.
- [ ] Fonts: Larger, smaller and bold text.
- [ ] High contrast.
- [ ] Talkback.
- [ ] Languages with large words or with letters/accents not frequently used in English.
- [ ] Right-to-left languages. (Even if translation isn’t complete, formatting should still respect the right-to-left layout)
- [ ] Large and small screen sizes. (Tablet and smaller phones)
- [ ] Multi-tasking: Split screen and Pop-up view. (Android 10 or higher)

[Dynamic Cards] Update Endpoint Response
### Endpoint
`sites/{SITE_ID}/dashboard/cards-data`

### The Full Response:
```
{
  "todays_stats": {...},
  "posts": {...},
  "pages": [...],
  "activity": {...},
  "dynamic": [Card, Card, ...]
}
```

### The Card Object:
```
{
  "id": "year_in_review_2023",
  "title": "News",
  "remote_feature_flag": "dynamic_dashboard_cards",
  "featured_image": "https://path/to/image",
  "url": "https://wordpress.com",
  "action": "Call to action",
  "order": "top",
  "rows": [
    {
      "icon": "https://path/to/image",
      "title": "xyz",
      "description": "xyz"
    },
    {
      "icon": "https://path/to/image",
      "title": "xyz",
      "description": "xyz"
    }
  ]
}
```
order will have 2 possible values: “top” and “bottom”.

diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSource.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSource.kt
index 182673578d15..9339b1d9abe8 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSource.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSource.kt
@@ -17,6 +17,7 @@ import org.wordpress.android.ui.mysite.MySiteUiState.PartialState.CardsUpdate
 import org.wordpress.android.ui.mysite.SelectedSiteRepository
 import org.wordpress.android.ui.mysite.cards.dashboard.activity.DashboardActivityLogCardFeatureUtils
 import org.wordpress.android.ui.prefs.AppPrefsWrapper
+import org.wordpress.android.util.config.DynamicDashboardCardsFeatureConfig
 import javax.inject.Inject
 import javax.inject.Named

@@ -27,7 +28,8 @@ class CardsSource @Inject constructor(
     private val cardsStore: CardsStore,
     private val dashboardActivityLogCardFeatureUtils: DashboardActivityLogCardFeatureUtils,
     @param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher,
-    private val appPrefsWrapper: AppPrefsWrapper
+    private val appPrefsWrapper: AppPrefsWrapper,
+    private val dynamicDashboardCardsFeatureConfig: DynamicDashboardCardsFeatureConfig,
 ) : MySiteRefreshSource<CardsUpdate> {
     override val refresh = MutableLiveData(false)

@@ -103,6 +105,7 @@ class CardsSource @Inject constructor(
         if (shouldRequestPagesCard(selectedSite)) add(Type.PAGES)
         if (dashboardActivityLogCardFeatureUtils.shouldRequestActivityCard(selectedSite)) add(Type.ACTIVITY)
         add(Type.POSTS)
+        if (shouldRequestDynamicCards()) add(Type.DYNAMIC)
     }.toList()

     private fun shouldRequestPagesCard(selectedSite: SiteModel): Boolean {
@@ -114,6 +117,10 @@ class CardsSource @Inject constructor(
         return !appPrefsWrapper.getShouldHideTodaysStatsDashboardCard(selectedSite.siteId)
     }

+    private fun shouldRequestDynamicCards(): Boolean {
+        return dynamicDashboardCardsFeatureConfig.isEnabled() // TODO We should also add check for hidden cards here
+    }
+
     private fun MediatorLiveData<CardsUpdate>.postErrorState() {
         val lastStateCards = this.value?.cards
         val showErrorCard = lastStateCards.isNullOrEmpty()
diff --git a/build.gradle b/build.gradle
index 9c8e92369cfa..574f4058ec29 100644
--- a/build.gradle
+++ b/build.gradle
@@ -24,7 +24,7 @@ ext {
     automatticTracksVersion = '3.3.0'
     gutenbergMobileVersion = 'v1.110.0-alpha1'
     wordPressAztecVersion = 'v1.9.0'
-    wordPressFluxCVersion = '2.59.0'
+    wordPressFluxCVersion = 'trunk-01d386ea4c0a9dbda7c06432e555300a97a231f5'
     wordPressLoginVersion = '1.10.0'
     wordPressPersistentEditTextVersion = '1.0.2'
     wordPressUtilsVersion = '3.10.0'

diff --git a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSourceTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSourceTest.kt
index 01014309a99e..08c87200a9f7 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSourceTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/mysite/cards/dashboard/CardsSourceTest.kt
@@ -19,6 +19,10 @@ import org.wordpress.android.fluxc.model.dashboard.CardModel.PostsCardModel.Post
 import org.wordpress.android.fluxc.model.dashboard.CardModel.PagesCardModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.PagesCardModel.PageCardModel
 import org.wordpress.android.fluxc.model.dashboard.CardModel.TodaysStatsCardModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.CardOrder
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.DynamicCardModel
+import org.wordpress.android.fluxc.model.dashboard.CardModel.DynamicCardsModel.DynamicCardRowModel
 import org.wordpress.android.fluxc.network.rest.wpcom.dashboard.CardsUtils
 import org.wordpress.android.fluxc.store.dashboard.CardsStore
 import org.wordpress.android.fluxc.store.dashboard.CardsStore.CardsError
@@ -29,6 +33,7 @@ import org.wordpress.android.ui.mysite.MySiteUiState.PartialState.CardsUpdate
 import org.wordpress.android.ui.mysite.SelectedSiteRepository
 import org.wordpress.android.ui.mysite.cards.dashboard.activity.DashboardActivityLogCardFeatureUtils
 import org.wordpress.android.ui.prefs.AppPrefsWrapper
+import org.wordpress.android.util.config.DynamicDashboardCardsFeatureConfig

 /* SITE */

@@ -57,6 +62,18 @@ const val PAGE_MODIFIED_ON = "2023-03-02 10:26:53"
 const val PAGE_STATUS = "publish"
 const val PAGE_DATE = "2023-03-02 10:30:53"

+/* DYNAMIC CARDS */
+const val DYNAMIC_CARD_ID = "year_in_review_2023"
+const val DYNAMIC_CARD_TITLE = "News"
+const val DYNAMIC_CARD_REMOTE_FEATURE_FLAG = "dynamic_dashboard_cards"
+const val DYNAMIC_CARD_FEATURED_IMAGE = "https://path/to/image"
+const val DYNAMIC_CARD_URL = "https://wordpress.com"
+const val DYNAMIC_CARD_ACTION = "Call to action"
+const val DYNAMIC_CARD_ORDER = "top"
+const val DYNAMIC_CARD_ROW_ICON = "https://path/to/image"
+const val DYNAMIC_CARD_ROW_TITLE = "Row title"
+const val DYNAMIC_CARD_ROW_DESCRIPTION = "Row description"
+
 /* ACTIVITY */
 const val ACTIVITY_ID = "activity123"
 const val ACTIVITY_SUMMARY = "activity"
@@ -110,6 +127,27 @@ private val PAGES_MODEL = PagesCardModel(
     pages = listOf(PAGE_MODEL)
 )

+private val DYNAMIC_CARD_ROW_MODEL = DynamicCardRowModel(
+    icon = DYNAMIC_CARD_ROW_ICON,
+    title = DYNAMIC_CARD_ROW_TITLE,
+    description = DYNAMIC_CARD_ROW_DESCRIPTION
+)
+
+private val DYNAMIC_CARD_MODEL = DynamicCardModel(
+    id = DYNAMIC_CARD_ID,
+    title = DYNAMIC_CARD_TITLE,
+    remoteFeatureFlag = DYNAMIC_CARD_REMOTE_FEATURE_FLAG,
+    featuredImage = DYNAMIC_CARD_FEATURED_IMAGE,
+    url = DYNAMIC_CARD_URL,
+    action = DYNAMIC_CARD_ACTION,
+    order = CardOrder.fromString(DYNAMIC_CARD_ORDER),
+    rows = listOf(DYNAMIC_CARD_ROW_MODEL)
+)
+
+private val DYNAMIC_CARDS_MODEL = DynamicCardsModel(
+    pages = listOf(DYNAMIC_CARD_MODEL)
+)
+
 private val ACTIVITY_LOG_MODEL = ActivityLogModel(
     summary = ACTIVITY_SUMMARY,
     content = FormattableContent(text = ACTIVITY_CONTENT),
@@ -138,7 +176,8 @@ private val CARDS_MODEL: List<CardModel> = listOf(
     TODAYS_STATS_CARDS_MODEL,
     POSTS_MODEL,
     PAGES_MODEL,
-    ACTIVITY_CARD_MODEL
+    ACTIVITY_CARD_MODEL,
+    DYNAMIC_CARDS_MODEL,
 )

 private val DEFAULT_CARD_TYPE = listOf(CardModel.Type.TODAYS_STATS, CardModel.Type.POSTS)
@@ -146,6 +185,8 @@ private val PAGES_FEATURED_ENABLED_CARD_TYPE =
     listOf(CardModel.Type.TODAYS_STATS, CardModel.Type.PAGES, CardModel.Type.POSTS)
 private val ACTIVITY_FEATURED_ENABLED_CARD_TYPE =
     listOf(CardModel.Type.TODAYS_STATS, CardModel.Type.ACTIVITY, CardModel.Type.POSTS)
+private val DYNAMIC_CARDS_ENABLED_CARD_TYPE =
+    listOf(CardModel.Type.TODAYS_STATS, CardModel.Type.POSTS, CardModel.Type.DYNAMIC)

 @ExperimentalCoroutinesApi
 class CardsSourceTest : BaseUnitTest() {
@@ -164,6 +205,9 @@ class CardsSourceTest : BaseUnitTest() {
     @Mock
     private lateinit var appPrefsWrapper: AppPrefsWrapper

+    @Mock
+    private lateinit var dynamicDashboardCardsFeatureConfig: DynamicDashboardCardsFeatureConfig
+
     private lateinit var cardSource: CardsSource

     private val data = CardsResult(
@@ -181,7 +225,8 @@ class CardsSourceTest : BaseUnitTest() {
             cardsStore,
             dashboardActivityLogCardFeatureUtils,
             testDispatcher(),
-            appPrefsWrapper
+            appPrefsWrapper,
+            dynamicDashboardCardsFeatureConfig
         )
     }

@@ -189,7 +234,8 @@ class CardsSourceTest : BaseUnitTest() {
         isDashboardCardActivityLogEnabled: Boolean = false,
         isRequestPages: Boolean = false,
         isPagesCardHidden: Boolean = false,
-        isTodaysStatsCardHidden: Boolean = false
+        isTodaysStatsCardHidden: Boolean = false,
+        isDynamicCardsEnabled: Boolean = false,
     ) {
         whenever(siteModel.id).thenReturn(SITE_LOCAL_ID)
         whenever(selectedSiteRepository.getSelectedSite()).thenReturn(siteModel)
@@ -200,6 +246,8 @@ class CardsSourceTest : BaseUnitTest() {
             .thenReturn(isPagesCardHidden)
         whenever(appPrefsWrapper.getShouldHideTodaysStatsDashboardCard(any()))
             .thenReturn(isTodaysStatsCardHidden)
+        whenever(dynamicDashboardCardsFeatureConfig.isEnabled())
+            .thenReturn(isDynamicCardsEnabled)
     }

     /* GET DATA */
@@ -232,7 +280,8 @@ class CardsSourceTest : BaseUnitTest() {
     fun `given build is invoked, when cards are collected, then data is loaded (database)`() = test {
         setUpMocks(
             isDashboardCardActivityLogEnabled = true,
-            isRequestPages = true
+            isRequestPages = true,
+            isDynamicCardsEnabled = true
         )
         val result = mutableListOf<CardsUpdate>()
         whenever(cardsStore.getCards(siteModel)).thenReturn(flowOf(data))
@@ -264,7 +313,8 @@ class CardsSourceTest : BaseUnitTest() {
     fun `given no error, when build is invoked, then data is only loaded from get cards (database)`() = test {
         setUpMocks(
             isDashboardCardActivityLogEnabled = true,
-            isRequestPages = true
+            isRequestPages = true,
+            isDynamicCardsEnabled = true
         )
         val result = mutableListOf<CardsUpdate>()
         whenever(cardsStore.getCards(siteModel)).thenReturn(flowOf(data))
@@ -531,6 +581,23 @@ class CardsSourceTest : BaseUnitTest() {
             verify(cardsStore).fetchCards(siteModel, ACTIVITY_FEATURED_ENABLED_CARD_TYPE)
         }

+    @Test
+    fun `given dynamic cards are enabled, when refresh is invoked, then dynamic cards are requested from network`() =
+        test {
+            setUpMocks(isDynamicCardsEnabled = true)
+            val result = mutableListOf<CardsUpdate>()
+            whenever(cardsStore.getCards(siteModel)).thenReturn(flowOf(data))
+            whenever(cardsStore.fetchCards(siteModel, DYNAMIC_CARDS_ENABLED_CARD_TYPE)).thenReturn(success)
+            cardSource.refresh.observeForever { }
+
+            cardSource.build(testScope(), SITE_LOCAL_ID).observeForever {
+                it?.let { result.add(it) }
+            }
+            advanceUntilIdle()
+
+            verify(cardsStore).fetchCards(siteModel, DYNAMIC_CARDS_ENABLED_CARD_TYPE)
+        }
+
     @Test
     fun `given activity feature disabled, when refresh is invoked, then activity not requested`() =
         test {


In-app reviews

Fixes #12605

This adds [in-app reviews feature](https://developer.android.com/guide/playcore/in-app-review). The prompt will display after the second successful publishing (post or story). It must be a new post, and editing doesn't count.

https://github.com/wordpress-mobile/WordPress-Android/assets/2471769/4914894f-3819-4a89-96ed-a7af502611fe

-----

## To Test:

<!-- Test instructions per dependency update: https://github.com/wordpress-mobile/WordPress-Android/blob/trunk/docs/test_instructions_per_dependency_update.md -->

Testing this feature is not available within the PR. It will be tested outside of the PR.
Internal link: p1702317430036659-slack-C441E3YTS

1. Install the app from the link.
2. Log in.
3. Publish a post or story.
4. Verify that the blogging reminder is displayed.
5. Publish another post or story.
6. Verify that the in-app previews prompt is displayed. (Submitting the review may not be available due to the testing environment.)

-----

## Regression Notes

1. Potential unintended areas of impact

    - None

7. What I did to test those areas of impact (or what existing automated tests I relied on)

    - Nothing

8. What automated tests I added (or what prevented me from doing so)

    - `ReviewViewModelTest`

-----

## PR Submission Checklist:

- [x] I have completed the Regression Notes.
- [x] I have considered adding accessibility improvements for my changes.
- [x] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.

-----

## UI Changes Testing Checklist:

- [x] Portrait and landscape orientations.
- [x] Light and dark modes.
- [x] Fonts: Larger, smaller and bold text.
- [x] High contrast.
- [x] Talkback.
- [x] Languages with large words or with letters/accents not frequently used in English.
- [x] Right-to-left languages. (Even if translation isn’t complete, formatting should still respect the right-to-left layout)
- [x] Large and small screen sizes. (Tablet and smaller phones)
- [x] Multi-tasking: Split screen and Pop-up view. (Android 10 or higher)

Add an in-app review prompt

Add an in-app review prompt
Google recently released a [new In-App Review API for Google Play reviews](https://android-developers.googleblog.com/2020/08/in-app-review-api.html). From their [developer docs](https://developer.android.com/guide/playcore/in-app-review):

> The Google Play In-App Review API lets you prompt users to submit Play Store ratings and reviews without the inconvenience of leaving your app or game.
>
> Generally, the in-app review flow can be triggered at any time throughout the user journey of your app. During the flow, the user has the ability to rate your app using the 1 to 5 star system and to add an optional comment. Once submitted, the review is sent to the Play Store and eventually displayed.

Naturally, there are some restrictions to consider:

> To protect user privacy and avoid API misuse, there are strict guidelines that your app should follow about when to request in-app reviews and the design of the review prompt.

Let's figure out how to integrate this into WPAndroid!

/cc @mattmiklic @frosty @yaelirub

This is cool! I'd suggest making the prompt appear after user successfully publishes a new post or page.
We already implement the existing app rating system – @khaykov, would you be able to take a quick look and see how different this is from what we use already?
@frosty It is pretty different. Currently, we just show a dialog that takes user to the Play Store where he has to write a review. The new review prompt and API are superior, and we will probably get more reviews from them since you can do it without leaving an app. I checked the guidelines and we are in a clear with our current and new flow.

We can also integrate it with a current logic that determines when to show it.
In terms of design, could we re-use the existing prompt to review, but have it lead to this new in-app UI instead of linking out to the Play Store?
@mattmiklic No, If I understand the guidelines correctly:

> Your app should not ask the user any questions before or while presenting the rating button or card, including questions about their opinion (such as “Do you like the app?”) or predictive questions (such as “Would you rate this app 5 stars”).
Ahh ok, thanks for clarifying that. I had a chance to look through the documentation and it sounds like we'd be required to figure out situations in which it would feel natural and unobstructive to make this modal dialog appear.

I think this requires greater sensitivity than our current prompt in Notifications (are there more?) because there will be no ability to present the option to review the app in-line with what they're already doing. The only way to trigger this is in a modal sheet, which if timed incorrectly, could be annoying to the user and potentially lead to a bad review.

So I think we should take a little time to think about when we'd want to show the user this kind of sheet. After publishing is just one idea, but I'm sure there may be more.

./gradlew :WordPress:testWordpressVanillaDebugUnitTest --tests "org.wordpress.android.ui.review.ReviewViewModelTest"

diff --git a/WordPress/build.gradle b/WordPress/build.gradle
index a982ada743d9..fef136d1799e 100644
--- a/WordPress/build.gradle
+++ b/WordPress/build.gradle
@@ -421,6 +421,8 @@ dependencies {
     implementation "androidx.lifecycle:lifecycle-livedata-ktx:$androidxLifecycleVersion"
     implementation "androidx.lifecycle:lifecycle-process:$androidxLifecycleVersion"
     implementation "com.android.volley:volley:$androidVolleyVersion"
+    implementation "com.google.android.play:review:$googlePlayReviewVersion"
+    implementation "com.google.android.play:review-ktx:$googlePlayReviewVersion"
     implementation "com.google.android.gms:play-services-auth:$googlePlayServicesAuthVersion"
     implementation "com.google.android.gms:play-services-code-scanner:$googlePlayServicesCodeScannerVersion"
     implementation "com.google.mlkit:barcode-scanning-common:$googleMLKitBarcodeScanningVersion"
diff --git a/WordPress/src/main/java/org/wordpress/android/modules/ViewModelModule.java b/WordPress/src/main/java/org/wordpress/android/modules/ViewModelModule.java
index 510ce7ee3119..e759a219bb18 100644
--- a/WordPress/src/main/java/org/wordpress/android/modules/ViewModelModule.java
+++ b/WordPress/src/main/java/org/wordpress/android/modules/ViewModelModule.java
@@ -33,13 +33,13 @@
 import org.wordpress.android.ui.posts.EditorBloggingPromptsViewModel;
 import org.wordpress.android.ui.posts.EditorJetpackSocialViewModel;
 import org.wordpress.android.ui.posts.PostListMainViewModel;
-import org.wordpress.android.ui.posts.prepublishing.categories.addcategory.PrepublishingAddCategoryViewModel;
+import org.wordpress.android.ui.posts.editor.StorePostViewModel;
+import org.wordpress.android.ui.posts.prepublishing.PrepublishingViewModel;
 import org.wordpress.android.ui.posts.prepublishing.categories.PrepublishingCategoriesViewModel;
+import org.wordpress.android.ui.posts.prepublishing.categories.addcategory.PrepublishingAddCategoryViewModel;
 import org.wordpress.android.ui.posts.prepublishing.home.PrepublishingHomeViewModel;
-import org.wordpress.android.ui.posts.prepublishing.tags.PrepublishingTagsViewModel;
-import org.wordpress.android.ui.posts.prepublishing.PrepublishingViewModel;
-import org.wordpress.android.ui.posts.editor.StorePostViewModel;
 import org.wordpress.android.ui.posts.prepublishing.publishsettings.PrepublishingPublishSettingsViewModel;
+import org.wordpress.android.ui.posts.prepublishing.tags.PrepublishingTagsViewModel;
 import org.wordpress.android.ui.prefs.accountsettings.AccountSettingsViewModel;
 import org.wordpress.android.ui.prefs.homepage.HomepageSettingsViewModel;
 import org.wordpress.android.ui.prefs.language.LocalePickerViewModel;
@@ -53,6 +53,7 @@
 import org.wordpress.android.ui.reader.viewmodels.ReaderPostListViewModel;
 import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel;
 import org.wordpress.android.ui.reader.viewmodels.SubfilterPageViewModel;
+import org.wordpress.android.ui.review.ReviewViewModel;
 import org.wordpress.android.ui.stats.refresh.lists.DaysListViewModel;
 import org.wordpress.android.ui.stats.refresh.lists.InsightsDetailListViewModel;
 import org.wordpress.android.ui.stats.refresh.lists.InsightsListViewModel;
@@ -465,6 +466,11 @@ abstract class ViewModelModule {
     @ViewModelKey(UnifiedCommentListViewModel.class)
     abstract ViewModel unifiedCommentListViewModel(UnifiedCommentListViewModel viewModel);

+    @Binds
+    @IntoMap
+    @ViewModelKey(ReviewViewModel.class)
+    abstract ViewModel reviewViewModel(ReviewViewModel viewModel);
+
     @Binds
     @IntoMap
     @ViewModelKey(BloggingRemindersViewModel.class)
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java
index 9cd30bdad3df..b7cac907852d 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java
@@ -27,7 +27,13 @@

 import com.google.android.gms.common.ConnectionResult;
 import com.google.android.gms.common.GoogleApiAvailability;
+import com.google.android.gms.tasks.Task;
 import com.google.android.material.floatingactionbutton.FloatingActionButton;
+import com.google.android.play.core.review.ReviewException;
+import com.google.android.play.core.review.ReviewInfo;
+import com.google.android.play.core.review.ReviewManager;
+import com.google.android.play.core.review.ReviewManagerFactory;
+import com.google.android.play.core.review.model.ReviewErrorCode;

 import org.greenrobot.eventbus.EventBus;
 import org.greenrobot.eventbus.Subscribe;
@@ -95,11 +101,11 @@
 import org.wordpress.android.ui.main.WPMainNavigationView.OnPageListener;
 import org.wordpress.android.ui.main.WPMainNavigationView.PageType;
 import org.wordpress.android.ui.mlp.ModalLayoutPickerFragment;
+import org.wordpress.android.ui.mysite.BloggingPromptsOnboardingListener;
 import org.wordpress.android.ui.mysite.MySiteFragment;
 import org.wordpress.android.ui.mysite.MySiteViewModel;
 import org.wordpress.android.ui.mysite.SelectedSiteRepository;
 import org.wordpress.android.ui.mysite.cards.quickstart.QuickStartRepository;
-import org.wordpress.android.ui.mysite.BloggingPromptsOnboardingListener;
 import org.wordpress.android.ui.notifications.NotificationEvents;
 import org.wordpress.android.ui.notifications.NotificationsListFragment;
 import org.wordpress.android.ui.notifications.SystemNotificationsTracker;
@@ -125,6 +131,7 @@
 import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask;
 import org.wordpress.android.ui.reader.services.update.ReaderUpdateServiceStarter;
 import org.wordpress.android.ui.reader.tracker.ReaderTracker;
+import org.wordpress.android.ui.review.ReviewViewModel;
 import org.wordpress.android.ui.sitecreation.misc.SiteCreationSource;
 import org.wordpress.android.ui.stats.StatsTimeframe;
 import org.wordpress.android.ui.stories.intro.StoriesIntroDialogFragment;
@@ -237,6 +244,7 @@ public class WPMainActivity extends LocaleAwareActivity implements

     private WPMainActivityViewModel mViewModel;
     private ModalLayoutPickerViewModel mMLPViewModel;
+    @NonNull private ReviewViewModel mReviewViewModel;
     private BloggingRemindersViewModel mBloggingRemindersViewModel;
     private FloatingActionButton mFloatingActionButton;
     private static final String MAIN_BOTTOM_SHEET_TAG = "MAIN_BOTTOM_SHEET_TAG";
@@ -654,6 +662,7 @@ private void initViewModel() {

         mViewModel = new ViewModelProvider(this, mViewModelFactory).get(WPMainActivityViewModel.class);
         mMLPViewModel = new ViewModelProvider(this, mViewModelFactory).get(ModalLayoutPickerViewModel.class);
+        mReviewViewModel = new ViewModelProvider(this, mViewModelFactory).get(ReviewViewModel.class);
         mBloggingRemindersViewModel =
                 new ViewModelProvider(this, mViewModelFactory).get(BloggingRemindersViewModel.class);

@@ -752,6 +761,11 @@ private void initViewModel() {
             });
         });

+        mReviewViewModel.getLaunchReview().observe(this, event -> event.applyIfNotHandled(unit -> {
+            launchInAppReviews();
+            return null;
+        }));
+
         BloggingReminderUtils.observeBottomSheet(
                 mBloggingRemindersViewModel.isBottomSheetShowing(),
                 this,
@@ -827,6 +841,25 @@ private void triggerCreatePageFlow(ActionType actionType) {
         }
     }

+    private void launchInAppReviews() {
+        ReviewManager manager = ReviewManagerFactory.create(this);
+        Task<ReviewInfo> request = manager.requestReviewFlow();
+        request.addOnCompleteListener(task -> {
+            if (task.isSuccessful()) {
+                ReviewInfo reviewInfo = task.getResult();
+                Task<Void> flow = manager.launchReviewFlow(this, reviewInfo);
+                flow.addOnFailureListener(e -> AppLog.e(T.MAIN, "Error launching google review API flow.", e));
+            } else {
+                @ReviewErrorCode int reviewErrorCode = ((ReviewException) task.getException()).getErrorCode();
+                AppLog.e(
+                        T.MAIN,
+                        "Error fetching ReviewInfo object from Review API to start in-app review process",
+                        reviewErrorCode
+                );
+            }
+        });
+    }
+
     private CreatePageDashboardSource getCreatePageDashboardSourceFromActionType(ActionType actionType) {
         if (actionType == ActionType.CREATE_NEW_PAGE_FROM_PAGES_CARD) {
             return CreatePageDashboardSource.PAGES_CARD;
@@ -1336,14 +1369,11 @@ public void onActivityResult(int requestCode, int resultCode, Intent data) {
                             post,
                             site,
                             mUploadActionUseCase.getUploadAction(post),
-                            new View.OnClickListener() {
-                                @Override
-                                public void onClick(View v) {
-                                    UploadUtils.publishPost(WPMainActivity.this, post, site, mDispatcher);
-                                }
-                            },
-                            isFirstTimePublishing -> mBloggingRemindersViewModel
-                                    .onPublishingPost(site.getId(), isFirstTimePublishing)
+                            v -> UploadUtils.publishPost(WPMainActivity.this, post, site, mDispatcher),
+                            isFirstTimePublishing -> {
+                                mBloggingRemindersViewModel.onPublishingPost(site.getId(), isFirstTimePublishing);
+                                mReviewViewModel.onPublishingPost(isFirstTimePublishing);
+                            }
                     );
                 }
                 break;
@@ -1355,6 +1385,7 @@ public void onClick(View v) {
                             selectedSite.getId(),
                             isNewStory
                     );
+                    mReviewViewModel.onPublishingPost(isNewStory);
                 }
                 break;
             case RequestCodes.CREATE_SITE:
@@ -1741,8 +1772,10 @@ public void onPostUploaded(OnPostUploaded event) {
                         event.post,
                         null,
                         targetSite,
-                        isFirstTimePublishing -> mBloggingRemindersViewModel
-                                .onPublishingPost(targetSite.getId(), isFirstTimePublishing)
+                        isFirstTimePublishing -> {
+                            mBloggingRemindersViewModel.onPublishingPost(targetSite.getId(), isFirstTimePublishing);
+                            mReviewViewModel.onPublishingPost(isFirstTimePublishing);
+                        }
                 );
             }
         }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt
index bfb562c03fbe..007fa44e5b05 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt
@@ -21,6 +21,9 @@ import androidx.appcompat.widget.Toolbar
 import androidx.lifecycle.ViewModelProvider
 import androidx.viewpager.widget.ViewPager.OnPageChangeListener
 import com.google.android.material.snackbar.Snackbar
+import com.google.android.play.core.review.ReviewException
+import com.google.android.play.core.review.ReviewManagerFactory
+import com.google.android.play.core.review.model.ReviewErrorCode
 import org.wordpress.android.R
 import org.wordpress.android.WordPress
 import org.wordpress.android.databinding.PostListActivityBinding
@@ -53,6 +56,7 @@ import org.wordpress.android.ui.posts.prepublishing.PrepublishingBottomSheetFrag
 import org.wordpress.android.ui.posts.prepublishing.PrepublishingBottomSheetFragment.Companion.newInstance
 import org.wordpress.android.ui.posts.prepublishing.home.PublishPost
 import org.wordpress.android.ui.posts.prepublishing.listeners.PrepublishingBottomSheetListener
+import org.wordpress.android.ui.review.ReviewViewModel
 import org.wordpress.android.ui.stories.StoriesMediaPickerResultHandler
 import org.wordpress.android.ui.uploads.UploadActionUseCase
 import org.wordpress.android.ui.uploads.UploadUtilsWrapper
@@ -124,6 +128,9 @@ class PostsListActivity : LocaleAwareActivity(),
     @Inject
     internal lateinit var bloggingRemindersViewModel: BloggingRemindersViewModel

+    @Inject
+    internal lateinit var reviewViewModel: ReviewViewModel
+
     @Inject
     internal lateinit var blazeFeatureUtils: BlazeFeatureUtils

@@ -209,6 +216,7 @@ class PostsListActivity : LocaleAwareActivity(),
             setupContent()
             initViewModel(initPreviewState, currentBottomSheetPostId)
             initBloggingReminders()
+            initInAppReviews()
             initTabLayout(tabIndex)
             loadIntentData(intent)
         }
@@ -336,6 +344,32 @@ class PostsListActivity : LocaleAwareActivity(),
         }
     }

+    private fun initInAppReviews() {
+        reviewViewModel = ViewModelProvider(this@PostsListActivity, viewModelFactory)[ReviewViewModel::class.java]
+        reviewViewModel.launchReview.observeEvent(this) { launchInAppReviews() }
+    }
+
+    private fun launchInAppReviews() {
+        val manager = ReviewManagerFactory.create(this)
+        val request = manager.requestReviewFlow()
+        request.addOnCompleteListener { task ->
+            if (task.isSuccessful) {
+                val reviewInfo = task.result
+                val flow = manager.launchReviewFlow(this, reviewInfo)
+                flow.addOnFailureListener { e ->
+                    AppLog.e(AppLog.T.POSTS, "Error launching google review API flow.", e)
+                }
+            } else {
+                @ReviewErrorCode val reviewErrorCode = (task.exception as ReviewException).errorCode
+                AppLog.e(
+                    AppLog.T.POSTS,
+                    "Error fetching ReviewInfo object from Review API to start in-app review process",
+                    reviewErrorCode
+                )
+            }
+        }
+    }
+
     private fun setupActions() {
         viewModel.dialogAction.observe(this@PostsListActivity) {
             it?.show(this@PostsListActivity, supportFragmentManager, uiHelpers)
@@ -350,6 +384,7 @@ class PostsListActivity : LocaleAwareActivity(),
                     uploadUtilsWrapper
                 ) { isFirstTimePublishing ->
                     bloggingRemindersViewModel.onPublishingPost(site.id, isFirstTimePublishing)
+                    reviewViewModel.onPublishingPost(isFirstTimePublishing)
                 }
             }
         }
@@ -458,6 +493,7 @@ class PostsListActivity : LocaleAwareActivity(),
                     site.id,
                     isNewStory
                 )
+                reviewViewModel.onPublishingPost(isNewStory)
             }
         }
     }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java
index ebbc64b69f60..672f98cb5085 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefs.java
@@ -171,6 +171,9 @@ public enum DeletablePrefKey implements PrefKey {
         SITE_JETPACK_CAPABILITIES,
         REMOVED_QUICK_START_CARD_TYPE,
         PINNED_DYNAMIC_CARD,
+        // PUBLISHED_POST_COUNT will increase until it reaches ReviewViewModel.TARGET_COUNT_POST_PUBLISHED
+        PUBLISHED_POST_COUNT,
+        IN_APP_REVIEW_SHOWN,
         BLOGGING_REMINDERS_SHOWN,
         SHOULD_SCHEDULE_CREATE_SITE_NOTIFICATION,
         SHOULD_SHOW_WEEKLY_ROUNDUP_NOTIFICATION,
@@ -1364,6 +1367,22 @@ public static boolean hasManualFeatureConfig(String featureKey) {
         return DeletablePrefKey.MANUAL_FEATURE_CONFIG.name() + featureKey;
     }

+    public static void incrementPublishedPostCount() {
+        putInt(DeletablePrefKey.PUBLISHED_POST_COUNT, getPublishedPostCount() + 1);
+    }
+
+    public static int getPublishedPostCount() {
+        return prefs().getInt(DeletablePrefKey.PUBLISHED_POST_COUNT.name(), 0);
+    }
+
+    public static void setInAppReviewsShown() {
+        putBoolean(DeletablePrefKey.IN_APP_REVIEW_SHOWN, true);
+    }
+
+    public static boolean isInAppReviewsShown() {
+        return prefs().getBoolean(DeletablePrefKey.IN_APP_REVIEW_SHOWN.name(), false);
+    }
+
     public static void setBloggingRemindersShown(int siteId) {
         prefs().edit().putBoolean(getBloggingRemindersConfigKey(siteId), true).apply();
     }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt
index 6a02a0c17915..78357a4280bd 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/prefs/AppPrefsWrapper.kt
@@ -183,6 +183,22 @@ class AppPrefsWrapper @Inject constructor() {
         return AppPrefs.getManualFeatureConfig(featureKey)
     }

+    fun incrementPublishedPostCount() {
+        AppPrefs.incrementPublishedPostCount()
+    }
+
+    fun getPublishedPostCount(): Int {
+        return AppPrefs.getPublishedPostCount()
+    }
+
+    fun setInAppReviewsShown() {
+        AppPrefs.setInAppReviewsShown()
+    }
+
+    fun isInAppReviewsShown(): Boolean {
+        return AppPrefs.isInAppReviewsShown()
+    }
+
     fun setBloggingRemindersShown(siteId: Int) {
         AppPrefs.setBloggingRemindersShown(siteId)
     }
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/review/ReviewViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/review/ReviewViewModel.kt
new file mode 100644
index 000000000000..579f97bc187f
--- /dev/null
+++ b/WordPress/src/main/java/org/wordpress/android/ui/review/ReviewViewModel.kt
@@ -0,0 +1,35 @@
+package org.wordpress.android.ui.review
+
+import androidx.annotation.VisibleForTesting
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.ViewModel
+import org.wordpress.android.ui.prefs.AppPrefsWrapper
+import org.wordpress.android.util.config.InAppReviewsFeatureConfig
+import org.wordpress.android.viewmodel.Event
+import javax.inject.Inject
+
+class ReviewViewModel @Inject constructor(
+    private val appPrefsWrapper: AppPrefsWrapper,
+    private val inAppReviewsFeatureConfig: InAppReviewsFeatureConfig
+) : ViewModel() {
+    private val _launchReview = MutableLiveData<Event<Unit>>()
+    val launchReview = _launchReview as LiveData<Event<Unit>>
+
+    fun onPublishingPost(isFirstTimePublishing: Boolean) {
+        if (inAppReviewsFeatureConfig.isEnabled() && !appPrefsWrapper.isInAppReviewsShown() && isFirstTimePublishing) {
+            if (appPrefsWrapper.getPublishedPostCount() < TARGET_COUNT_POST_PUBLISHED) {
+                appPrefsWrapper.incrementPublishedPostCount()
+            }
+            if (appPrefsWrapper.getPublishedPostCount() == TARGET_COUNT_POST_PUBLISHED) {
+                _launchReview.value = Event(Unit)
+                appPrefsWrapper.setInAppReviewsShown()
+            }
+        }
+    }
+
+    companion object {
+        @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+        const val TARGET_COUNT_POST_PUBLISHED = 2
+    }
+}
diff --git a/build.gradle b/build.gradle
index 44d6b2f96300..143c62272b1d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -70,6 +70,7 @@ ext {
     googleMaterialVersion = '1.9.0'

     googleMLKitBarcodeScanningVersion = '17.0.0'
+    googlePlayReviewVersion = '2.0.1'
     googlePlayServicesAuthVersion = '20.4.1'
     googlePlayServicesCodeScannerVersion = '16.0.0-beta3'
     jsoupVersion = '1.16.2'

diff --git a/WordPress/src/test/java/org/wordpress/android/ui/review/ReviewViewModelTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/review/ReviewViewModelTest.kt
new file mode 100644
index 000000000000..be459d325fe5
--- /dev/null
+++ b/WordPress/src/test/java/org/wordpress/android/ui/review/ReviewViewModelTest.kt
@@ -0,0 +1,69 @@
+package org.wordpress.android.ui.review
+
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.Mock
+import org.mockito.junit.MockitoJUnitRunner
+import org.mockito.kotlin.whenever
+import org.wordpress.android.eventToList
+import org.wordpress.android.ui.prefs.AppPrefsWrapper
+import org.wordpress.android.util.config.InAppReviewsFeatureConfig
+import kotlin.test.assertEquals
+
+@RunWith(MockitoJUnitRunner::class)
+class ReviewViewModelTest {
+    @Rule
+    @JvmField
+    val rule = InstantTaskExecutorRule()
+
+    @Mock
+    lateinit var inAppReviewsFeatureConfig: InAppReviewsFeatureConfig
+
+    @Mock
+    lateinit var appPrefsWrapper: AppPrefsWrapper
+
+    private lateinit var viewModel: ReviewViewModel
+
+    private lateinit var events: MutableList<Unit>
+
+    @Before
+    fun setup() {
+        whenever(inAppReviewsFeatureConfig.isEnabled()).thenReturn(true)
+        viewModel = ReviewViewModel(appPrefsWrapper, inAppReviewsFeatureConfig)
+        events = mutableListOf()
+        events = viewModel.launchReview.eventToList()
+    }
+
+    @Test
+    fun onPublishingPost_whenPublishedCountIsLow_doNotLaunchInAppReviews() {
+        whenever(appPrefsWrapper.isInAppReviewsShown()).thenReturn(false)
+        whenever(appPrefsWrapper.getPublishedPostCount()).thenReturn(ReviewViewModel.TARGET_COUNT_POST_PUBLISHED - 1)
+
+        viewModel.onPublishingPost(true)
+
+        assertEquals(events.size, 0)
+    }
+
+    @Test
+    fun onPublishingPost_whenInAppReviewsAlreadyShown_doNotLaunchInAppReviews() {
+        whenever(appPrefsWrapper.isInAppReviewsShown()).thenReturn(true)
+
+        viewModel.onPublishingPost(true)
+
+        assertEquals(events.size, 0)
+    }
+
+    @Test
+    fun onPublishingPost_whenPublishedCountIsHigh_launchInAppReviews() {
+        whenever(appPrefsWrapper.isInAppReviewsShown()).thenReturn(false)
+        whenever(appPrefsWrapper.getPublishedPostCount()).thenReturn(ReviewViewModel.TARGET_COUNT_POST_PUBLISHED)
+
+        viewModel.onPublishingPost(true)
+
+        // Verify `launchReview` is triggered.
+        assertEquals(Unit, events.last())
+    }
+}
diff --git a/docs/test_instructions_per_dependency_update.md b/docs/test_instructions_per_dependency_update.md
index 9617747e26d6..60ef32eec1cc 100644
--- a/docs/test_instructions_per_dependency_update.md
+++ b/docs/test_instructions_per_dependency_update.md
@@ -54,6 +54,7 @@ rather than strict requirements.
     3. [MLKitBarcodeScanning](#mlkitbarcodescanning)
     4. [PlayServicesAuth](#playservicesauth)
     5. [PlayServicesCoreScanner](#playservicescodescanner)
+    6. [PlayReview](#playreview)
 5. Network
     1. [Okio](#okio)
 6. Tool
@@ -421,6 +422,19 @@ Step.3:

 -----

+### PlayReview [[googlePlayReviewVersion](https://github.com/wordpress-mobile/WordPress-Android/blob/trunk/build.gradle)]
+
+
+    1. In app reviews
+
+- Perform a clean install.
+- Publish three (`ReviewViewModel.TARGET_COUNT_POST_PUBLISHED + 1`) new posts or stories.
+- Verify that there are no crashes.
+
+
+
+-----
+
 ### Okio [[squareupOkioVersion](https://github.com/wordpress-mobile/WordPress-Android/blob/trunk/build.gradle)]


[Prompts] Fix response list paging regression

Fixes #19728

The correct code was already in production until the release of 23.7, when it was removed because of an old PR (created before the correct code fix was merged in the past) that was rebased and I didn't realize it reverted to the wrong behavior.

PR that introduced the regression: https://github.com/wordpress-mobile/WordPress-Android/pull/18340

This PR basically reintroduces the fix made by #18276 that was reverted by the culprit PR above:

-----

## To Test:
1. Install and log into the Jetpack app
2. Make sure the `dailyprompt-XXXX` tag for the current day IS NOT FOLLOWED
3. Go to the dashboard
4. Tap `View all responses` on the Prompts card
5. Scroll the prompt tag responses list for a while
6. **Verify** pagination is working properly (posts are not shifting around, just being added at the end)
7. **Verify** posts keep loading when reaching the end of the feed as long as there are posts available

-----

## Regression Notes

1. Potential unintended areas of impact

    - N/A

2. What I did to test those areas of impact (or what existing automated tests I relied on)

    - N/A

3. What automated tests I added (or what prevented me from doing so)

    - Update relevant unit test.

-----

## PR Submission Checklist:

- [x] I have completed the Regression Notes.
- [x] I have considered adding accessibility improvements for my changes.
- [x] I have considered if this change warrants user-facing release notes and have added them to `RELEASE-NOTES.txt` if necessary.

-----

## UI Changes Testing Checklist:

- [ ] Portrait and landscape orientations.
- [ ] Light and dark modes.
- [ ] Fonts: Larger, smaller and bold text.
- [ ] High contrast.
- [ ] Talkback.
- [ ] Languages with large words or with letters/accents not frequently used in English.
- [ ] Right-to-left languages. (Even if translation isn’t complete, formatting should still respect the right-to-left layout)
- [ ] Large and small screen sizes. (Tablet and smaller phones)
- [ ] Multi-tasking: Split screen and Pop-up view. (Android 10 or higher)

[Prompts] Regression in Prompts responses list pagination
### Expected behavior
When tapping the `View all responses` and scrolling the response list I should be able to get an infinite scroll with proper pagination until I see all responses. New posts are added to the end of the list when loaded. (it was working on version 23.6 and below)

### Actual behavior
When tapping the `View all responses` and scrolling the response list I see the new responses loading once or twice but then no more posts are loaded. Posts shift places when a load happens. (this behavior started on version 23.7)

### Steps to reproduce the behavior
On any Jetpack app build of version 23.7 or later do the following:
1. Install and log into the Jetpack app
2. Make sure the `dailyprompt-XXXX` tag for the current day IS NOT FOLLOWED
3. Go to the dashboard
4. Tap `View all responses` on the Prompts card
5. Scroll the prompt tag responses list for a while
6. **Verify** pagination is not working properly (posts are shifting around)
7. **Verify** pagination stops loading new posts at some point

##### Tested on Emulator, Android 14, JPAndroid 23.7 and later

diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt
index f7ef4c88e694..1d298b006c2f 100644
--- a/RELEASE-NOTES.txt
+++ b/RELEASE-NOTES.txt
@@ -11,6 +11,7 @@
 * [*] Block Editor: In the deeply nested block warning, only display the ungroup option for blocks that support it [https://github.com/WordPress/gutenberg/pull/56445]
 * [**] Enable Optimize Image setting (via Me → App Settings) by default, and change default compression and resolution values. [https://github.com/wordpress-mobile/WordPress-Android/pull/19581]
 * [*] Fixed an issue that prevented theme installation on atomic sites [https://github.com/wordpress-mobile/WordPress-Android/pull/19668]
+* [*] Fixed an issue with the pagination of the Blogging Prompts response list [https://github.com/wordpress-mobile/WordPress-Android/pull/19730]

 23.7
 -----
diff --git a/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProvider.kt b/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProvider.kt
index 0600bbcff449..4aef7e8c43dd 100644
--- a/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProvider.kt
+++ b/WordPress/src/main/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProvider.kt
@@ -2,6 +2,7 @@ package org.wordpress.android.ui.bloggingprompts

 import org.wordpress.android.models.ReaderTag
 import org.wordpress.android.models.ReaderTagType
+import org.wordpress.android.ui.reader.services.post.ReaderPostLogic
 import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper
 import javax.inject.Inject

@@ -16,10 +17,16 @@ class BloggingPromptsPostTagProvider @Inject constructor(

     fun promptIdSearchReaderTag(
         tagUrl: String
-    ): ReaderTag = readerUtilsWrapper.getTagFromTagName(
-        promptIdTag(tagUrl),
-        ReaderTagType.FOLLOWED,
-    )
+    ): ReaderTag {
+        val promptIdTag = promptIdTag(tagUrl)
+        return ReaderTag(
+            promptIdTag,
+            promptIdTag,
+            promptIdTag,
+            ReaderPostLogic.formatFullEndpointForTag(promptIdTag),
+            ReaderTagType.FOLLOWED,
+        )
+    }

     companion object {
         const val BLOGGING_PROMPT_TAG = "dailyprompt"

diff --git a/WordPress/src/test/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProviderTest.kt b/WordPress/src/test/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProviderTest.kt
index 8c901817c0f7..942681c91225 100644
--- a/WordPress/src/test/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProviderTest.kt
+++ b/WordPress/src/test/java/org/wordpress/android/ui/bloggingprompts/BloggingPromptsPostTagProviderTest.kt
@@ -5,12 +5,12 @@ import org.assertj.core.api.Assertions.assertThat
 import org.junit.Before
 import org.junit.Test
 import org.mockito.Mock
-import org.mockito.Mockito.mock
 import org.mockito.kotlin.any
 import org.mockito.kotlin.whenever
 import org.wordpress.android.BaseUnitTest
 import org.wordpress.android.models.ReaderTag
 import org.wordpress.android.models.ReaderTagType
+import org.wordpress.android.ui.reader.services.post.ReaderPostLogic
 import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper
 import kotlin.test.assertEquals

@@ -23,9 +23,6 @@ class BloggingPromptsPostTagProviderTest : BaseUnitTest() {

     @Before
     fun setUp() {
-        whenever(readerUtilsWrapper.getTagFromTagName(BLOGGING_PROMPT_ID_TAG, ReaderTagType.FOLLOWED))
-            .thenReturn(BLOGGING_PROMPT_ID_READER_TAG)
-
         tagProvider = BloggingPromptsPostTagProvider(readerUtilsWrapper)
     }

@@ -50,14 +47,18 @@ class BloggingPromptsPostTagProviderTest : BaseUnitTest() {
     @Test
     fun `Should return the expected ReaderTag when promptIdSearchReaderTag is called`() {
         whenever(readerUtilsWrapper.getTagFromTagUrl(any())).thenReturn(BLOGGING_PROMPT_ID_TAG)
-
+        val expected = ReaderTag(
+            BLOGGING_PROMPT_ID_TAG,
+            BLOGGING_PROMPT_ID_TAG,
+            BLOGGING_PROMPT_ID_TAG,
+            ReaderPostLogic.formatFullEndpointForTag(BLOGGING_PROMPT_ID_TAG),
+            ReaderTagType.FOLLOWED,
+        )
         val actual = tagProvider.promptIdSearchReaderTag("valid-url")
-
-        assertEquals(BLOGGING_PROMPT_ID_READER_TAG, actual)
+        assertEquals(expected, actual)
     }

     companion object {
         private const val BLOGGING_PROMPT_ID_TAG = "dailyprompt-1234"
-        private val BLOGGING_PROMPT_ID_READER_TAG = mock<ReaderTag>()
     }
 }