For #21045: Add categories support

upstream-sync
Mugurell 3 years ago committed by mergify[bot]
parent ccc0f17e4f
commit ba4c44afcf

@ -0,0 +1,109 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.ext
import androidx.annotation.VisibleForTesting
import mozilla.components.service.pocket.PocketRecommendedStory
import org.mozilla.fenix.home.HomeFragmentState
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.POCKET_STORIES_DEFAULT_CATEGORY_NAME
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory
/**
* Get the list of stories to be displayed.
* Either the stories from the [POCKET_STORIES_DEFAULT_CATEGORY_NAME] either
* filtered stories based on the user selected categories.
*
* @param neededStoriesCount how many stories are intended to be displayed.
* This only impacts filtered results guaranteeing an even spread of stories from each category.
*
* @return a list of [PocketRecommendedStory]es from the currently selected categories
* topped if necessary with stories from the [POCKET_STORIES_DEFAULT_CATEGORY_NAME] up to [neededStoriesCount].
*/
fun HomeFragmentState.getFilteredStories(
neededStoriesCount: Int
): List<PocketRecommendedStory> {
val currentlySelectedCategories = pocketStoriesCategories.filter { it.isSelected }
if (currentlySelectedCategories.isEmpty()) {
return pocketStoriesCategories
.find {
it.name == POCKET_STORIES_DEFAULT_CATEGORY_NAME
}?.stories?.take(neededStoriesCount) ?: emptyList()
}
val oldestSortedCategories = currentlySelectedCategories
.sortedBy { it.lastInteractedWithTimestamp }
val filteredStoriesCount = getFilteredStoriesCount(
pocketStoriesCategories, oldestSortedCategories, neededStoriesCount
)
// Add general stories at the end of the stories list to show until neededStoriesCount
val generalStoriesTopup = filteredStoriesCount[POCKET_STORIES_DEFAULT_CATEGORY_NAME]?.let { neededTopups ->
pocketStoriesCategories.find { it.name == POCKET_STORIES_DEFAULT_CATEGORY_NAME }?.stories?.take(neededTopups)
} ?: emptyList()
return oldestSortedCategories
.flatMap { it.stories.take(filteredStoriesCount[it.name]!!) }
.plus(generalStoriesTopup)
.take(neededStoriesCount)
}
/**
* Get how many stories needs to be shown from each currently selected category.
*
* If the selected categories together don't have [neededStoriesCount] stories then
* the difference is added from the [POCKET_STORIES_DEFAULT_CATEGORY_NAME] category.
*
* @param allCategories the list of all Pocket stories categories.
* @param selectedCategories ordered list of categories from which to return results.
* @param neededStoriesCount how many stories are intended to be displayed.
* This impacts the results by guaranteeing an even spread of stories from each category in that stories count.
*
* @return a mapping of how many stories are to be shown from each category from [selectedCategories].
* The result is topped with stories counts from the [POCKET_STORIES_DEFAULT_CATEGORY_NAME] up to [neededStoriesCount].
*/
@VisibleForTesting
@Suppress("ReturnCount", "NestedBlockDepth")
internal fun getFilteredStoriesCount(
allCategories: List<PocketRecommendedStoryCategory>,
selectedCategories: List<PocketRecommendedStoryCategory>,
neededStoriesCount: Int
): Map<String, Int> {
val totalStoriesInFilteredCategories = selectedCategories.fold(0) { availableStories, category ->
availableStories + category.stories.size
}
when {
totalStoriesInFilteredCategories == neededStoriesCount -> {
return selectedCategories.map { it.name to it.stories.size }.toMap()
}
totalStoriesInFilteredCategories < neededStoriesCount -> {
return selectedCategories.map { it.name to it.stories.size }.toMap() +
allCategories.filter { it.name == POCKET_STORIES_DEFAULT_CATEGORY_NAME }.map {
it.name to (neededStoriesCount - totalStoriesInFilteredCategories).coerceAtMost(it.stories.size)
}.toMap()
}
else -> {
val storiesCountFromEachCategory = mutableMapOf<String, Int>()
var currentFilteredStoriesCount = 0
for (i in 0 until selectedCategories.maxOf { it.stories.size }) {
selectedCategories.forEach { category ->
if (category.stories.getOrNull(i) != null) {
storiesCountFromEachCategory[category.name] =
storiesCountFromEachCategory[category.name]?.inc() ?: 1
if (++currentFilteredStoriesCount == neededStoriesCount) {
return storiesCountFromEachCategory
}
}
}
}
}
}
return emptyMap()
}

@ -47,7 +47,6 @@ import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map
@ -111,6 +110,8 @@ import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
import org.mozilla.fenix.home.sessioncontrol.SessionControlView
import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.DefaultPocketStoriesController
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory
import org.mozilla.fenix.home.sessioncontrol.viewholders.topsites.DefaultTopSitesView
import org.mozilla.fenix.onboarding.FenixOnboarding
import org.mozilla.fenix.settings.SupportUtils
@ -241,9 +242,12 @@ class HomeFragment : Fragment() {
}
if (requireContext().settings().pocketRecommendations) {
lifecycleScope.async(IO) {
val stories = components.core.pocketStoriesService.getStories()
homeFragmentStore.dispatch(HomeFragmentAction.PocketStoriesChange(stories))
lifecycleScope.launch(IO) {
val categories = components.core.pocketStoriesService.getStories()
.groupBy { story -> story.category }
.map { (category, stories) -> PocketRecommendedStoryCategory(category, stories) }
homeFragmentStore.dispatch(HomeFragmentAction.PocketStoriesCategoriesChange(categories))
}
}
@ -327,6 +331,9 @@ class HomeFragment : Fragment() {
),
historyMetadataController = DefaultHistoryMetadataController(
navController = findNavController()
),
pocketStoriesController = DefaultPocketStoriesController(
homeStore = homeFragmentStore
)
)

@ -15,7 +15,10 @@ import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
import mozilla.components.service.pocket.PocketRecommendedStory
import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.ext.getFilteredStories
import org.mozilla.fenix.historymetadata.HistoryMetadataGroup
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.POCKET_STORIES_TO_SHOW_COUNT
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory
/**
* The [Store] for holding the [HomeFragmentState] and applying [HomeFragmentAction]s.
@ -63,7 +66,8 @@ data class HomeFragmentState(
val recentTabs: List<TabSessionState> = emptyList(),
val recentBookmarks: List<BookmarkNode> = emptyList(),
val historyMetadata: List<HistoryMetadataGroup> = emptyList(),
val pocketStories: List<PocketRecommendedStory> = emptyList()
val pocketStories: List<PocketRecommendedStory> = emptyList(),
val pocketStoriesCategories: List<PocketRecommendedStoryCategory> = emptyList()
) : State
sealed class HomeFragmentAction : Action {
@ -89,11 +93,16 @@ sealed class HomeFragmentAction : Action {
data class RecentTabsChange(val recentTabs: List<TabSessionState>) : HomeFragmentAction()
data class RecentBookmarksChange(val recentBookmarks: List<BookmarkNode>) : HomeFragmentAction()
data class HistoryMetadataChange(val historyMetadata: List<HistoryMetadataGroup>) : HomeFragmentAction()
data class SelectPocketStoriesCategory(val categoryName: String) : HomeFragmentAction()
data class DeselectPocketStoriesCategory(val categoryName: String) : HomeFragmentAction()
data class PocketStoriesChange(val pocketStories: List<PocketRecommendedStory>) : HomeFragmentAction()
data class PocketStoriesCategoriesChange(val storiesCategories: List<PocketRecommendedStoryCategory>) :
HomeFragmentAction()
object RemoveCollectionsPlaceholder : HomeFragmentAction()
object RemoveSetDefaultBrowserCard : HomeFragmentAction()
}
@Suppress("ReturnCount")
private fun homeFragmentStateReducer(
state: HomeFragmentState,
action: HomeFragmentAction
@ -132,6 +141,43 @@ private fun homeFragmentStateReducer(
is HomeFragmentAction.RecentTabsChange -> state.copy(recentTabs = action.recentTabs)
is HomeFragmentAction.RecentBookmarksChange -> state.copy(recentBookmarks = action.recentBookmarks)
is HomeFragmentAction.HistoryMetadataChange -> state.copy(historyMetadata = action.historyMetadata)
is HomeFragmentAction.SelectPocketStoriesCategory -> {
// Selecting a category means the stories to be displayed needs to also be changed.
val updatedCategoriesState = state.copy(
pocketStoriesCategories = state.pocketStoriesCategories.map {
when (it.name == action.categoryName) {
true -> it.copy(isSelected = true)
false -> it
}
}
)
return updatedCategoriesState.copy(
pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT)
)
}
is HomeFragmentAction.DeselectPocketStoriesCategory -> {
val updatedCategoriesState = state.copy(
// Deselecting a category means the stories to be displayed needs to also be changed.
pocketStoriesCategories = state.pocketStoriesCategories.map {
when (it.name == action.categoryName) {
true -> it.copy(isSelected = false)
false -> it
}
}
)
return updatedCategoriesState.copy(
pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT)
)
}
is HomeFragmentAction.PocketStoriesCategoriesChange -> {
// Whenever categories change stories to be displayed needs to also be changed.
val updatedCategoriesState = state.copy(pocketStoriesCategories = action.storiesCategories)
return updatedCategoriesState.copy(
pocketStories = updatedCategoriesState.getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT)
).also {
println("just updated stories in the state")
}
}
is HomeFragmentAction.PocketStoriesChange -> state.copy(pocketStories = action.pocketStories)
}
}

@ -233,7 +233,8 @@ class SessionControlAdapter(
PocketStoriesViewHolder.LAYOUT_ID -> return PocketStoriesViewHolder(
ComposeView(parent.context),
store,
components.core.client
components.core.client,
interactor = interactor
)
RecentTabViewHolder.LAYOUT_ID -> return RecentTabViewHolder(
composeView = ComposeView(parent.context),

@ -17,6 +17,9 @@ import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksControll
import org.mozilla.fenix.home.recentbookmarks.interactor.RecentBookmarksInteractor
import org.mozilla.fenix.home.recenttabs.controller.RecentTabController
import org.mozilla.fenix.home.recenttabs.interactor.RecentTabInteractor
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketStoriesController
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketStoriesInteractor
/**
* Interface for tab related actions in the [SessionControlInteractor].
@ -216,14 +219,15 @@ interface ExperimentCardInteractor {
/**
* Interactor for the Home screen. Provides implementations for the CollectionInteractor,
* OnboardingInteractor, TopSiteInteractor, TipInteractor, TabSessionInteractor,
* ToolbarInteractor, ExperimentCardInteractor, RecentTabInteractor, and RecentBookmarksInteractor.
* ToolbarInteractor, ExperimentCardInteractor, RecentTabInteractor, RecentBookmarksInteractor and others.
*/
@SuppressWarnings("TooManyFunctions")
class SessionControlInteractor(
private val controller: SessionControlController,
private val recentTabController: RecentTabController,
private val recentBookmarksController: RecentBookmarksController,
private val historyMetadataController: HistoryMetadataController
private val historyMetadataController: HistoryMetadataController,
private val pocketStoriesController: PocketStoriesController
) : CollectionInteractor,
OnboardingInteractor,
TopSiteInteractor,
@ -234,7 +238,8 @@ class SessionControlInteractor(
RecentTabInteractor,
RecentBookmarksInteractor,
HistoryMetadataInteractor,
CustomizeHomeIteractor {
CustomizeHomeIteractor,
PocketStoriesInteractor {
override fun onCollectionAddTabTapped(collection: TabCollection) {
controller.handleCollectionAddTabTapped(collection)
@ -365,4 +370,8 @@ class SessionControlInteractor(
override fun openCustomizeHomePage() {
controller.handleCustomizeHomeTapped()
}
override fun onCategoryClick(categoryClicked: PocketRecommendedStoryCategory) {
pocketStoriesController.handleCategoryClick(categoryClicked)
}
}

@ -0,0 +1,30 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket
import mozilla.components.service.pocket.PocketRecommendedStory
/**
* Category name of the default category from which stories are to be shown
* if user hasn't explicitly selected others.
*/
const val POCKET_STORIES_DEFAULT_CATEGORY_NAME = "general"
/**
* Pocket assigned topic of interest for each story.
*
* One to many relationship with [PocketRecommendedStory]es.
*
* @property name The exact name of each category. Case sensitive.
* @property stories All [PocketRecommendedStory]es with this category.
* @property isSelected Whether this category is currently selected by the user.
* @property lastInteractedWithTimestamp Last time the user selected or deselected this category.
*/
data class PocketRecommendedStoryCategory(
val name: String,
val stories: List<PocketRecommendedStory> = emptyList(),
val isSelected: Boolean = false,
val lastInteractedWithTimestamp: Long = 0L
)

@ -11,11 +11,14 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@ -26,6 +29,7 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Card
import androidx.compose.material.ContentAlpha
import androidx.compose.material.ExperimentalMaterialApi
@ -33,6 +37,7 @@ import androidx.compose.material.Icon
import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@ -46,6 +51,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@ -54,6 +61,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import mozilla.components.concept.fetch.Client
import mozilla.components.concept.fetch.MutableHeaders
@ -182,6 +191,157 @@ fun PocketStories(
}
}
/**
* Displays a list of [PocketRecommendedStoryCategory].
*
* @param categories the categories needed to be displayed.
* @param onCategoryClick callback for when the user taps a category.
*/
@Composable
fun PocketStoriesCategories(
categories: List<PocketRecommendedStoryCategory>,
onCategoryClick: (PocketRecommendedStoryCategory) -> Unit
) {
StaggeredHorizontalGrid {
categories.forEach { category ->
PocketStoryCategory(category) {
onCategoryClick(it)
}
}
}
}
/**
* Displays an individual [PocketRecommendedStoryCategory].
*
* @param category the categories needed to be displayed.
* @param onClick callback for when the user taps this category.
*/
@Composable
fun PocketStoryCategory(
category: PocketRecommendedStoryCategory,
onClick: (PocketRecommendedStoryCategory) -> Unit
) {
val contentColor = when (category.isSelected) {
true -> Color.Blue
false -> Color.DarkGray
}
OutlinedButton(
onClick = { onClick(category) },
shape = RoundedCornerShape(32.dp),
border = BorderStroke(1.dp, contentColor),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = contentColor
),
contentPadding = PaddingValues(8.dp, 7.dp)
) {
Row {
Text(
text = category.name,
modifier = Modifier.alignByBaseline(),
)
Icon(
painter = painterResource(id = R.drawable.mozac_ic_check),
contentDescription = "Expand or collapse Pocket recommended stories",
modifier = Modifier.alignByBaseline()
)
}
}
}
/**
* Displays a list of items as a staggered horizontal grid placing them on ltr rows and continuing
* on as many below rows as needed to place all items.
*
* In an effort to best utilize the available row space this can mix the items such that narrower ones
* are placed on the same row as wider ones if the otherwise next item doesn't fit.
*
* @param modifier to be applied to the layout.
* @param horizontalItemsSpacing minimum horizontal space between items. Does not add spacing to layout bounds.
* @param verticalItemsSpacing vertical space between items
* @param arrangement how the items will be horizontally aligned and spaced.
* @param content the children composables to be laid out.
*/
@Composable
fun StaggeredHorizontalGrid(
modifier: Modifier = Modifier,
horizontalItemsSpacing: Dp = 0.dp,
verticalItemsSpacing: Dp = 8.dp,
arrangement: Arrangement.Horizontal = Arrangement.SpaceEvenly,
content: @Composable () -> Unit
) {
Layout(content, modifier) { items, constraints ->
val horizontalItemsSpacingPixels = horizontalItemsSpacing.roundToPx()
val verticalItemsSpacingPixels = verticalItemsSpacing.roundToPx()
var totalHeight = 0
val itemsRows = mutableListOf<List<Placeable>>()
val notYetPlacedItems = items.map {
it.measure(constraints)
}.toMutableList()
fun getIndexOfNextPlaceableThatFitsRow(available: List<Placeable>, currentWidth: Int): Int {
return available.indexOfFirst {
currentWidth + it.width <= constraints.maxWidth
}
}
// Populate each row with as many items as possible combining wider with narrower items.
// This will change the order of shown categories.
var (currentRow, currentWidth) = mutableListOf<Placeable>() to 0
while (notYetPlacedItems.isNotEmpty()) {
if (currentRow.isEmpty()) {
currentRow.add(
notYetPlacedItems[0].also {
currentWidth += it.width
totalHeight += it.height + verticalItemsSpacingPixels
}
)
notYetPlacedItems.removeAt(0)
} else {
val nextPlaceableThatFitsIndex = getIndexOfNextPlaceableThatFitsRow(notYetPlacedItems, currentWidth)
if (nextPlaceableThatFitsIndex >= 0) {
currentRow.add(
notYetPlacedItems[nextPlaceableThatFitsIndex].also {
currentWidth += it.width + horizontalItemsSpacingPixels
}
)
notYetPlacedItems.removeAt(nextPlaceableThatFitsIndex)
} else {
itemsRows.add(currentRow)
currentRow = mutableListOf()
currentWidth = 0
}
}
}
if (currentRow.isNotEmpty()) {
itemsRows.add(currentRow)
}
totalHeight -= verticalItemsSpacingPixels
// Place each item from each row on screen.
layout(constraints.maxWidth, totalHeight) {
itemsRows.forEachIndexed { rowIndex, itemRow ->
val itemsSizes = IntArray(itemRow.size) {
itemRow[it].width +
if (it < itemRow.lastIndex) horizontalItemsSpacingPixels else 0
}
val itemsPositions = IntArray(itemsSizes.size) { 0 }
with(arrangement) {
arrange(constraints.maxWidth, itemsSizes, LayoutDirection.Ltr, itemsPositions)
}
itemRow.forEachIndexed { itemIndex, item ->
item.place(
x = itemsPositions[itemIndex],
y = (rowIndex * item.height) + (rowIndex * verticalItemsSpacingPixels)
)
}
}
}
}
}
/**
* Displays [content] in a layout which will have at the bottom more information about Pocket
* and also an external link for more up-to-date content.
@ -291,6 +451,14 @@ private fun FinalDesign() {
stories = getFakePocketStories(7),
client = FakeClient()
)
Spacer(Modifier.height(8.dp))
PocketStoriesCategories(
listOf("general", "health", "technology", "food", "career").map {
PocketRecommendedStoryCategory(it)
}
) { }
}
}
}

@ -0,0 +1,65 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket
import org.mozilla.fenix.home.HomeFragmentAction
import org.mozilla.fenix.home.HomeFragmentStore
import mozilla.components.lib.state.Store
/**
* Contract for how all user interactions with the Pocket recommended stories feature are to be handled.
*/
interface PocketStoriesController {
/**
* Callback allowing to handle a specific [PocketRecommendedStoryCategory] being clicked by the user.
*
* @param categoryClicked the just clicked [PocketRecommendedStoryCategory].
*/
fun handleCategoryClick(categoryClicked: PocketRecommendedStoryCategory): Unit
}
/**
* Default behavior for handling all user interactions with the Pocket recommended stories feature.
*
* @param homeStore [Store] from which to read the current Pocket recommendations and dispatch new actions on.
*/
internal class DefaultPocketStoriesController(
val homeStore: HomeFragmentStore
) : PocketStoriesController {
override fun handleCategoryClick(categoryClicked: PocketRecommendedStoryCategory) {
val allCategories = homeStore.state.pocketStoriesCategories
// First check whether the category is clicked to be deselected.
if (categoryClicked.isSelected) {
homeStore.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(categoryClicked.name))
return
}
// If a new category is clicked to be selected:
// Ensure the number of categories selected at a time is capped.
val currentlySelectedCategoriesCount = allCategories.fold(0) { count, category ->
if (category.isSelected) count + 1 else count
}
val oldestCategoryToDeselect =
if (currentlySelectedCategoriesCount == POCKET_CATEGORIES_SELECTED_AT_A_TIME_COUNT) {
allCategories
.filter { it.isSelected }
.reduce { oldestSelected, category ->
when (oldestSelected.lastInteractedWithTimestamp <= category.lastInteractedWithTimestamp) {
true -> oldestSelected
false -> category
}
}
} else {
null
}
oldestCategoryToDeselect?.let {
homeStore.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(it.name))
}
// Finally update the selection.
homeStore.dispatch(HomeFragmentAction.SelectPocketStoriesCategory(categoryClicked.name))
}
}

@ -0,0 +1,17 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket
/**
* Contract for all possible user interactions with the Pocket recommended stories feature.
*/
interface PocketStoriesInteractor {
/**
* Callback for when the user clicked a specific category.
*
* @param categoryClicked the just clicked [PocketRecommendedStoryCategory].
*/
fun onCategoryClick(categoryClicked: PocketRecommendedStoryCategory)
}

@ -5,7 +5,10 @@
package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket
import android.view.View
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@ -18,7 +21,8 @@ import mozilla.components.lib.state.ext.observeAsComposableState
import mozilla.components.service.pocket.PocketRecommendedStory
import org.mozilla.fenix.home.HomeFragmentStore
private const val STORIES_TO_SHOW_COUNT = 7
internal const val POCKET_STORIES_TO_SHOW_COUNT = 7
internal const val POCKET_CATEGORIES_SELECTED_AT_A_TIME_COUNT = 7
/**
* [RecyclerView.ViewHolder] that will display a list of [PocketRecommendedStory]es
@ -26,11 +30,14 @@ private const val STORIES_TO_SHOW_COUNT = 7
*
* @param composeView [ComposeView] which will be populated with Jetpack Compose UI content.
* @param store [HomeFragmentStore] containing the list of Pocket stories to be displayed.
* @param client [Client] instance used for the stories header images.
* @param interactor [PocketStoriesInteractor] callback for user interaction.
*/
class PocketStoriesViewHolder(
val composeView: ComposeView,
val store: HomeFragmentStore,
val client: Client
val client: Client,
val interactor: PocketStoriesInteractor
) : RecyclerView.ViewHolder(composeView) {
init {
@ -38,7 +45,7 @@ class PocketStoriesViewHolder(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
composeView.setContent {
PocketStories(store, client)
PocketStories(store, client) { interactor.onCategoryClick(it) }
}
}
@ -50,11 +57,14 @@ class PocketStoriesViewHolder(
@Composable
fun PocketStories(
store: HomeFragmentStore,
client: Client
client: Client,
onCategoryClick: (PocketRecommendedStoryCategory) -> Unit
) {
val stories = store
.observeAsComposableState { state -> state.pocketStories }.value
?.take(STORIES_TO_SHOW_COUNT)
val categories = store
.observeAsComposableState { state -> state.pocketStoriesCategories }.value
ExpandableCard(
Modifier
@ -62,10 +72,15 @@ fun PocketStories(
.padding(top = 40.dp)
) {
PocketRecommendations {
PocketStories(
stories ?: emptyList(),
client
)
Column {
PocketStories(stories ?: emptyList(), client)
Spacer(Modifier.height(8.dp))
PocketStoriesCategories(categories ?: emptyList()) {
onCategoryClick(it)
}
}
}
}
}

@ -0,0 +1,300 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.ext
import mozilla.components.service.pocket.PocketRecommendedStory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mozilla.fenix.home.HomeFragmentState
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.POCKET_STORIES_DEFAULT_CATEGORY_NAME
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory
import kotlin.random.Random
class HomeFragmentStateTest {
private val otherStoriesCategory =
PocketRecommendedStoryCategory("other", getFakePocketStories(3, "other"))
private val anotherStoriesCategory =
PocketRecommendedStoryCategory("another", getFakePocketStories(3, "another"))
private val defaultStoriesCategory = PocketRecommendedStoryCategory(
POCKET_STORIES_DEFAULT_CATEGORY_NAME,
getFakePocketStories(3)
)
@Test
fun `GIVEN no category is selected WHEN getFilteredStories is called THEN only Pocket stories from the default category are returned`() {
val homeState = HomeFragmentState(
pocketStoriesCategories = listOf(
otherStoriesCategory, anotherStoriesCategory, defaultStoriesCategory
)
)
var result = homeState.getFilteredStories(2)
assertNull(result.firstOrNull { it.category != POCKET_STORIES_DEFAULT_CATEGORY_NAME })
result = homeState.getFilteredStories(5)
assertNull(result.firstOrNull { it.category != POCKET_STORIES_DEFAULT_CATEGORY_NAME })
}
@Test
fun `GIVEN no category is selected WHEN getFilteredStories is called THEN no more than the indicated number of stories are returned`() {
val homeState = HomeFragmentState(
pocketStoriesCategories = listOf(
otherStoriesCategory, anotherStoriesCategory, defaultStoriesCategory
)
)
// Asking for fewer than available
var result = homeState.getFilteredStories(2)
assertEquals(2, result.size)
// Asking for more than available
result = homeState.getFilteredStories(5)
assertEquals(3, result.size)
}
@Test
fun `GIVEN a category is selected WHEN getFilteredStories is called for fewer than in the category THEN only stories from that category are returned`() {
val homeState = HomeFragmentState(
pocketStoriesCategories = listOf(
otherStoriesCategory.copy(isSelected = true), anotherStoriesCategory, defaultStoriesCategory
)
)
var result = homeState.getFilteredStories(2)
assertEquals(2, result.size)
assertNull(result.firstOrNull { it.category != otherStoriesCategory.name })
result = homeState.getFilteredStories(3)
assertEquals(3, result.size)
assertNull(result.firstOrNull { it.category != otherStoriesCategory.name })
}
@Test
fun `GIVEN a category is selected WHEN getFilteredStories is called for more than in the category THEN results topped with ones from the default category are returned`() {
val homeState = HomeFragmentState(
pocketStoriesCategories = listOf(
otherStoriesCategory.copy(isSelected = true), anotherStoriesCategory, defaultStoriesCategory
)
)
val result = homeState.getFilteredStories(5)
assertEquals(5, result.size)
assertEquals(3, result.filter { it.category == otherStoriesCategory.name }.size)
assertEquals(
2,
result.filter { it.category == POCKET_STORIES_DEFAULT_CATEGORY_NAME }.size
)
}
@Test
fun `GIVEN two categories are selected WHEN getFilteredStories is called for fewer than in both THEN only stories from those categories are returned`() {
val homeState = HomeFragmentState(
pocketStoriesCategories = listOf(
otherStoriesCategory.copy(isSelected = true),
anotherStoriesCategory.copy(isSelected = true),
defaultStoriesCategory
)
)
var result = homeState.getFilteredStories(2)
assertEquals(2, result.size)
assertNull(
result.firstOrNull {
it.category != otherStoriesCategory.name && it.category != anotherStoriesCategory.name
}
)
result = homeState.getFilteredStories(6)
assertEquals(6, result.size)
assertNull(
result.firstOrNull {
it.category != otherStoriesCategory.name && it.category != anotherStoriesCategory.name
}
)
}
@Test
fun `GIVEN two categories are selected WHEN getFilteredStories is called for more than in the categories THEN results topped with ones from the default category are returned`() {
val homeState = HomeFragmentState(
pocketStoriesCategories = listOf(
otherStoriesCategory.copy(isSelected = true),
anotherStoriesCategory.copy(isSelected = true),
defaultStoriesCategory
)
)
val result = homeState.getFilteredStories(8)
assertEquals(8, result.size)
assertEquals(3, result.filter { it.category == otherStoriesCategory.name }.size)
assertEquals(3, result.filter { it.category == anotherStoriesCategory.name }.size)
assertEquals(
2,
result.filter { it.category == POCKET_STORIES_DEFAULT_CATEGORY_NAME }.size
)
}
@Test
fun `GIVEN two categories are selected WHEN getFilteredStories is called for an odd number of stories THEN there are more by one stories from the oldest category`() {
val firstSelectedCategory = otherStoriesCategory.copy(lastInteractedWithTimestamp = 0, isSelected = true)
val lastSelectedCategory = anotherStoriesCategory.copy(lastInteractedWithTimestamp = 1, isSelected = true)
val homeState = HomeFragmentState(
pocketStoriesCategories = listOf(
firstSelectedCategory, lastSelectedCategory, defaultStoriesCategory
)
)
val result = homeState.getFilteredStories(5)
assertEquals(5, result.size)
assertEquals(3, result.filter { it.category == firstSelectedCategory.name }.size)
assertEquals(2, result.filter { it.category == lastSelectedCategory.name }.size)
}
@Test
fun `GIVEN no category is selected WHEN getFilteredStoriesCount is called THEN Pocket stories count only from the default category are returned`() {
val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory)
var result = getFilteredStoriesCount(availableCategories, emptyList(), 2)
assertEquals(1, result.keys.size)
assertEquals(defaultStoriesCategory.name, result.entries.first().key)
assertEquals(2, result[defaultStoriesCategory.name])
result = getFilteredStoriesCount(availableCategories, emptyList(), 5)
assertEquals(1, result.keys.size)
assertEquals(defaultStoriesCategory.name, result.entries.first().key)
assertEquals(3, result[defaultStoriesCategory.name])
}
@Test
fun `GIVEN a category is selected WHEN getFilteredStoriesCount is called for at most the stories from this category THEN only stories count only from that category are returned`() {
val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory)
var result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory), 2)
assertEquals(1, result.keys.size)
assertEquals(otherStoriesCategory.name, result.entries.first().key)
assertEquals(2, result[otherStoriesCategory.name])
result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory), 3)
assertEquals(1, result.keys.size)
assertEquals(otherStoriesCategory.name, result.entries.first().key)
assertEquals(3, result[otherStoriesCategory.name])
}
@Test
fun `GIVEN a category is selected WHEN getFilteredStoriesCount is called for more stories than this category has THEN results topped with ones from the default category are returned`() {
val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory)
val result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory), 5)
assertEquals(2, result.keys.size)
assertTrue(
result.keys.containsAll(
listOf(
defaultStoriesCategory.name,
otherStoriesCategory.name
)
)
)
assertEquals(3, result[otherStoriesCategory.name])
assertEquals(2, result[defaultStoriesCategory.name])
}
@Test
fun `GIVEN two categories are selected WHEN getFilteredStoriesCount is called for at most the stories count in both THEN only stories counts from those categories are returned`() {
val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory)
var result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory, anotherStoriesCategory), 2)
assertEquals(2, result.keys.size)
assertTrue(
result.keys.containsAll(
listOf(
otherStoriesCategory.name,
anotherStoriesCategory.name
)
)
)
assertEquals(1, result[otherStoriesCategory.name])
assertEquals(1, result[anotherStoriesCategory.name])
result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory, anotherStoriesCategory), 6)
assertEquals(2, result.keys.size)
assertTrue(
result.keys.containsAll(
listOf(
otherStoriesCategory.name,
anotherStoriesCategory.name
)
)
)
assertEquals(3, result[otherStoriesCategory.name])
assertEquals(3, result[anotherStoriesCategory.name])
}
@Test
fun `GIVEN two categories are selected WHEN getFilteredStoriesCount is called for more results than in those categories THEN results topped with ones from the default category are returned`() {
val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory)
val result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory, anotherStoriesCategory), 8)
assertEquals(3, result.size)
assertTrue(
result.keys.containsAll(
listOf(
defaultStoriesCategory.name,
otherStoriesCategory.name,
anotherStoriesCategory.name
)
)
)
assertEquals(3, result[otherStoriesCategory.name])
assertEquals(3, result[anotherStoriesCategory.name])
assertEquals(2, result[defaultStoriesCategory.name])
}
@Test
fun `GIVEN two categories are selected WHEN getFilteredStoriesCount is called for an odd number of results THEN there are more by one results from first selected category`() {
val availableCategories = listOf(otherStoriesCategory, defaultStoriesCategory, anotherStoriesCategory)
// The lastInteractedWithTimestamp is not checked in this method but the selected categories order
val result = getFilteredStoriesCount(availableCategories, listOf(otherStoriesCategory, anotherStoriesCategory), 5)
assertTrue(
result.keys.containsAll(
listOf(
otherStoriesCategory.name,
anotherStoriesCategory.name
)
)
)
assertEquals(3, result[otherStoriesCategory.name])
assertEquals(2, result[anotherStoriesCategory.name])
}
}
private fun getFakePocketStories(
limit: Int = 1,
category: String = POCKET_STORIES_DEFAULT_CATEGORY_NAME
): List<PocketRecommendedStory> {
return mutableListOf<PocketRecommendedStory>().apply {
for (index in 0 until limit) {
val randomNumber = Random.nextInt(0, 10)
add(
PocketRecommendedStory(
title = "This is a ${"very ".repeat(randomNumber)} long title",
publisher = "Publisher",
url = "https://story$randomNumber.com",
imageUrl = "",
timeToRead = randomNumber,
category = category
)
)
}
}
}

@ -7,21 +7,28 @@ package org.mozilla.fenix.home
import android.content.Context
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.top.sites.TopSite
import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.pocket.PocketRecommendedStory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getFilteredStories
import org.mozilla.fenix.historymetadata.HistoryMetadataGroup
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.POCKET_STORIES_TO_SHOW_COUNT
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory
import org.mozilla.fenix.onboarding.FenixOnboarding
class HomeFragmentStoreTest {
@ -179,4 +186,112 @@ class HomeFragmentStoreTest {
assertEquals(2, homeFragmentStore.state.historyMetadata.size)
assertEquals(Mode.Private, homeFragmentStore.state.mode)
}
@Test
fun `Test selecting a Pocket recommendations category`() = runBlocking {
val otherStoriesCategory = PocketRecommendedStoryCategory("other")
val anotherStoriesCategory = PocketRecommendedStoryCategory("another")
val filteredStories = listOf(mockk<PocketRecommendedStory>())
homeFragmentStore = HomeFragmentStore(
HomeFragmentState(
pocketStoriesCategories = listOf(
otherStoriesCategory, anotherStoriesCategory
)
)
)
mockkStatic("org.mozilla.fenix.ext.HomeFragmentStateKt") {
every { any<HomeFragmentState>().getFilteredStories(any()) } returns filteredStories
homeFragmentStore.dispatch(HomeFragmentAction.SelectPocketStoriesCategory("other")).join()
verify { any<HomeFragmentState>().getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) }
}
assertTrue(
listOf(otherStoriesCategory.copy(isSelected = true))
.containsAll(homeFragmentStore.state.pocketStoriesCategories.filter { it.isSelected })
)
assertSame(filteredStories, homeFragmentStore.state.pocketStories)
}
@Test
fun `Test deselecting a Pocket recommendations category`() = runBlocking {
val otherStoriesCategory = PocketRecommendedStoryCategory("other", isSelected = true)
val anotherStoriesCategory = PocketRecommendedStoryCategory("another", isSelected = true)
val filteredStories = listOf(mockk<PocketRecommendedStory>())
homeFragmentStore = HomeFragmentStore(
HomeFragmentState(
pocketStoriesCategories = listOf(
otherStoriesCategory, anotherStoriesCategory
)
)
)
mockkStatic("org.mozilla.fenix.ext.HomeFragmentStateKt") {
every { any<HomeFragmentState>().getFilteredStories(any()) } returns filteredStories
homeFragmentStore.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory("other")).join()
verify { any<HomeFragmentState>().getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) }
}
assertTrue(
listOf(anotherStoriesCategory)
.containsAll(homeFragmentStore.state.pocketStoriesCategories.filter { it.isSelected })
)
assertSame(filteredStories, homeFragmentStore.state.pocketStories)
}
@Test
fun `Test updating the list of Pocket recommended stories`() = runBlocking {
val story1 = PocketRecommendedStory("title1", "publisher", "url", "imageUrl", 1, "category")
val story2 = story1.copy("title2")
homeFragmentStore = HomeFragmentStore(HomeFragmentState())
homeFragmentStore.dispatch(HomeFragmentAction.PocketStoriesChange(listOf(story1, story2)))
.join()
assertTrue(homeFragmentStore.state.pocketStories.containsAll(listOf(story1, story2)))
val updatedStories = listOf(story2.copy("title3"))
homeFragmentStore.dispatch(HomeFragmentAction.PocketStoriesChange(updatedStories)).join()
assertTrue(updatedStories.containsAll(homeFragmentStore.state.pocketStories))
}
@Test
fun `Test updating the list of Pocket recommendations categories`() = runBlocking {
val otherStoriesCategory = PocketRecommendedStoryCategory("other")
val anotherStoriesCategory = PocketRecommendedStoryCategory("another", isSelected = true)
homeFragmentStore = HomeFragmentStore(HomeFragmentState())
mockkStatic("org.mozilla.fenix.ext.HomeFragmentStateKt") {
val firstFilteredStories = listOf(mockk<PocketRecommendedStory>())
every { any<HomeFragmentState>().getFilteredStories(any()) } returns firstFilteredStories
homeFragmentStore.dispatch(
HomeFragmentAction.PocketStoriesCategoriesChange(
listOf(otherStoriesCategory, anotherStoriesCategory)
)
).join()
verify { any<HomeFragmentState>().getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) }
assertTrue(
homeFragmentStore.state.pocketStoriesCategories.containsAll(
listOf(otherStoriesCategory, anotherStoriesCategory)
)
)
assertSame(firstFilteredStories, homeFragmentStore.state.pocketStories)
val updatedCategories = listOf(PocketRecommendedStoryCategory("yetAnother"))
val secondFilteredStories = listOf(mockk<PocketRecommendedStory>())
every { any<HomeFragmentState>().getFilteredStories(any()) } returns secondFilteredStories
homeFragmentStore.dispatch(
HomeFragmentAction.PocketStoriesCategoriesChange(
updatedCategories
)
).join()
verify(exactly = 2) { any<HomeFragmentState>().getFilteredStories(POCKET_STORIES_TO_SHOW_COUNT) }
assertTrue(updatedCategories.containsAll(homeFragmentStore.state.pocketStoriesCategories))
assertSame(secondFilteredStories, homeFragmentStore.state.pocketStories)
}
}
}

@ -22,6 +22,8 @@ import org.mozilla.fenix.home.recentbookmarks.controller.RecentBookmarksControll
import org.mozilla.fenix.home.recenttabs.controller.RecentTabController
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketRecommendedStoryCategory
import org.mozilla.fenix.home.sessioncontrol.viewholders.pocket.PocketStoriesController
class SessionControlInteractorTest {
@ -29,6 +31,7 @@ class SessionControlInteractorTest {
private val recentTabController: RecentTabController = mockk(relaxed = true)
private val recentBookmarksController: RecentBookmarksController = mockk(relaxed = true)
private val historyMetadataController: HistoryMetadataController = mockk(relaxed = true)
private val pocketStoriesController: PocketStoriesController = mockk(relaxed = true)
private lateinit var interactor: SessionControlInteractor
@ -38,7 +41,8 @@ class SessionControlInteractorTest {
controller,
recentTabController,
recentBookmarksController,
historyMetadataController
historyMetadataController,
pocketStoriesController
)
}
@ -222,4 +226,13 @@ class SessionControlInteractorTest {
interactor.onPrivateModeButtonClicked(newMode, hasBeenOnboarded)
verify { controller.handlePrivateModeButtonClicked(newMode, hasBeenOnboarded) }
}
@Test
fun `GIVEN a PocketStoriesInteractor WHEN a category is clicked THEN handle it in a PocketStoriesController`() {
val clickedCategory: PocketRecommendedStoryCategory = mockk()
interactor.onCategoryClick(clickedCategory)
verify { pocketStoriesController.handleCategoryClick(clickedCategory) }
}
}

@ -0,0 +1,93 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket
import io.mockk.spyk
import io.mockk.verify
import org.junit.Test
import org.mozilla.fenix.home.HomeFragmentAction
import org.mozilla.fenix.home.HomeFragmentState
import org.mozilla.fenix.home.HomeFragmentStore
class DefaultPocketStoriesControllerTest {
@Test
fun `GIVEN a category is selected WHEN that same category is clicked THEN deselect it`() {
val category1 = PocketRecommendedStoryCategory("cat1", emptyList(), isSelected = false)
val category2 = PocketRecommendedStoryCategory("cat2", emptyList(), isSelected = true)
val store = spyk(
HomeFragmentStore(
HomeFragmentState(pocketStoriesCategories = listOf(category1, category2))
)
)
val controller = DefaultPocketStoriesController(store)
controller.handleCategoryClick(category1)
verify(exactly = 0) { store.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(category1.name)) }
controller.handleCategoryClick(category2)
verify { store.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(category2.name)) }
}
@Test
fun `GIVEN 7 categories are selected WHEN when a new one is clicked THEN the oldest seleected is deselected before selecting the new one`() {
val category1 = PocketRecommendedStoryCategory(
"cat1", emptyList(), isSelected = true, lastInteractedWithTimestamp = 111
)
val category2 = category1.copy("cat2", lastInteractedWithTimestamp = 222)
val category3 = category1.copy("cat3", lastInteractedWithTimestamp = 333)
val oldestSelectedCategory = category1.copy("oldestSelectedCategory", lastInteractedWithTimestamp = 0)
val category4 = category1.copy("cat4", lastInteractedWithTimestamp = 444)
val category5 = category1.copy("cat5", lastInteractedWithTimestamp = 555)
val category6 = category1.copy("cat6", lastInteractedWithTimestamp = 678)
val newSelectedCategory = category1.copy(
"newSelectedCategory", isSelected = false, lastInteractedWithTimestamp = 654321
)
val store = spyk(
HomeFragmentStore(
HomeFragmentState(
pocketStoriesCategories = listOf(
category1, category2, category3, category4, category5, category6, oldestSelectedCategory
)
)
)
)
val controller = DefaultPocketStoriesController(store)
controller.handleCategoryClick(newSelectedCategory)
verify { store.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(oldestSelectedCategory.name)) }
verify { store.dispatch(HomeFragmentAction.SelectPocketStoriesCategory(newSelectedCategory.name)) }
}
@Test
fun `GIVEN fewer than 7 categories are selected WHEN when a new one is clicked THEN don't deselect anything but select the newly clicked category`() {
val category1 = PocketRecommendedStoryCategory(
"cat1", emptyList(), isSelected = true, lastInteractedWithTimestamp = 111
)
val category2 = category1.copy("cat2", lastInteractedWithTimestamp = 222)
val category3 = category1.copy("cat3", lastInteractedWithTimestamp = 333)
val oldestSelectedCategory = category1.copy("oldestSelectedCategory", lastInteractedWithTimestamp = 0)
val category4 = category1.copy("cat4", lastInteractedWithTimestamp = 444)
val category5 = category1.copy("cat5", lastInteractedWithTimestamp = 555)
val newSelectedCategory = category1.copy(
"newSelectedCategory", isSelected = false, lastInteractedWithTimestamp = 654321
)
val store = spyk(
HomeFragmentStore(
HomeFragmentState(
pocketStoriesCategories = listOf(
category1, category2, category3, category4, category5, oldestSelectedCategory
)
)
)
)
val controller = DefaultPocketStoriesController(store)
controller.handleCategoryClick(newSelectedCategory)
verify(exactly = 0) { store.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(oldestSelectedCategory.name)) }
verify { store.dispatch(HomeFragmentAction.SelectPocketStoriesCategory(newSelectedCategory.name)) }
}
}
Loading…
Cancel
Save