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.IO
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.map 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.SessionControlInteractor
import org.mozilla.fenix.home.sessioncontrol.SessionControlView import org.mozilla.fenix.home.sessioncontrol.SessionControlView
import org.mozilla.fenix.home.sessioncontrol.viewholders.CollectionViewHolder 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.home.sessioncontrol.viewholders.topsites.DefaultTopSitesView
import org.mozilla.fenix.onboarding.FenixOnboarding import org.mozilla.fenix.onboarding.FenixOnboarding
import org.mozilla.fenix.settings.SupportUtils import org.mozilla.fenix.settings.SupportUtils
@ -241,9 +242,12 @@ class HomeFragment : Fragment() {
} }
if (requireContext().settings().pocketRecommendations) { if (requireContext().settings().pocketRecommendations) {
lifecycleScope.async(IO) { lifecycleScope.launch(IO) {
val stories = components.core.pocketStoriesService.getStories() val categories = components.core.pocketStoriesService.getStories()
homeFragmentStore.dispatch(HomeFragmentAction.PocketStoriesChange(stories)) .groupBy { story -> story.category }
.map { (category, stories) -> PocketRecommendedStoryCategory(category, stories) }
homeFragmentStore.dispatch(HomeFragmentAction.PocketStoriesCategoriesChange(categories))
} }
} }
@ -327,6 +331,9 @@ class HomeFragment : Fragment() {
), ),
historyMetadataController = DefaultHistoryMetadataController( historyMetadataController = DefaultHistoryMetadataController(
navController = findNavController() 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.lib.state.Store
import mozilla.components.service.pocket.PocketRecommendedStory import mozilla.components.service.pocket.PocketRecommendedStory
import org.mozilla.fenix.components.tips.Tip import org.mozilla.fenix.components.tips.Tip
import org.mozilla.fenix.ext.getFilteredStories
import org.mozilla.fenix.historymetadata.HistoryMetadataGroup 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. * The [Store] for holding the [HomeFragmentState] and applying [HomeFragmentAction]s.
@ -63,7 +66,8 @@ data class HomeFragmentState(
val recentTabs: List<TabSessionState> = emptyList(), val recentTabs: List<TabSessionState> = emptyList(),
val recentBookmarks: List<BookmarkNode> = emptyList(), val recentBookmarks: List<BookmarkNode> = emptyList(),
val historyMetadata: List<HistoryMetadataGroup> = emptyList(), val historyMetadata: List<HistoryMetadataGroup> = emptyList(),
val pocketStories: List<PocketRecommendedStory> = emptyList() val pocketStories: List<PocketRecommendedStory> = emptyList(),
val pocketStoriesCategories: List<PocketRecommendedStoryCategory> = emptyList()
) : State ) : State
sealed class HomeFragmentAction : Action { sealed class HomeFragmentAction : Action {
@ -89,11 +93,16 @@ sealed class HomeFragmentAction : Action {
data class RecentTabsChange(val recentTabs: List<TabSessionState>) : HomeFragmentAction() data class RecentTabsChange(val recentTabs: List<TabSessionState>) : HomeFragmentAction()
data class RecentBookmarksChange(val recentBookmarks: List<BookmarkNode>) : HomeFragmentAction() data class RecentBookmarksChange(val recentBookmarks: List<BookmarkNode>) : HomeFragmentAction()
data class HistoryMetadataChange(val historyMetadata: List<HistoryMetadataGroup>) : 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 PocketStoriesChange(val pocketStories: List<PocketRecommendedStory>) : HomeFragmentAction()
data class PocketStoriesCategoriesChange(val storiesCategories: List<PocketRecommendedStoryCategory>) :
HomeFragmentAction()
object RemoveCollectionsPlaceholder : HomeFragmentAction() object RemoveCollectionsPlaceholder : HomeFragmentAction()
object RemoveSetDefaultBrowserCard : HomeFragmentAction() object RemoveSetDefaultBrowserCard : HomeFragmentAction()
} }
@Suppress("ReturnCount")
private fun homeFragmentStateReducer( private fun homeFragmentStateReducer(
state: HomeFragmentState, state: HomeFragmentState,
action: HomeFragmentAction action: HomeFragmentAction
@ -132,6 +141,43 @@ private fun homeFragmentStateReducer(
is HomeFragmentAction.RecentTabsChange -> state.copy(recentTabs = action.recentTabs) is HomeFragmentAction.RecentTabsChange -> state.copy(recentTabs = action.recentTabs)
is HomeFragmentAction.RecentBookmarksChange -> state.copy(recentBookmarks = action.recentBookmarks) is HomeFragmentAction.RecentBookmarksChange -> state.copy(recentBookmarks = action.recentBookmarks)
is HomeFragmentAction.HistoryMetadataChange -> state.copy(historyMetadata = action.historyMetadata) 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) is HomeFragmentAction.PocketStoriesChange -> state.copy(pocketStories = action.pocketStories)
} }
} }

@ -233,7 +233,8 @@ class SessionControlAdapter(
PocketStoriesViewHolder.LAYOUT_ID -> return PocketStoriesViewHolder( PocketStoriesViewHolder.LAYOUT_ID -> return PocketStoriesViewHolder(
ComposeView(parent.context), ComposeView(parent.context),
store, store,
components.core.client components.core.client,
interactor = interactor
) )
RecentTabViewHolder.LAYOUT_ID -> return RecentTabViewHolder( RecentTabViewHolder.LAYOUT_ID -> return RecentTabViewHolder(
composeView = ComposeView(parent.context), 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.recentbookmarks.interactor.RecentBookmarksInteractor
import org.mozilla.fenix.home.recenttabs.controller.RecentTabController import org.mozilla.fenix.home.recenttabs.controller.RecentTabController
import org.mozilla.fenix.home.recenttabs.interactor.RecentTabInteractor 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]. * Interface for tab related actions in the [SessionControlInteractor].
@ -216,14 +219,15 @@ interface ExperimentCardInteractor {
/** /**
* Interactor for the Home screen. Provides implementations for the CollectionInteractor, * Interactor for the Home screen. Provides implementations for the CollectionInteractor,
* OnboardingInteractor, TopSiteInteractor, TipInteractor, TabSessionInteractor, * OnboardingInteractor, TopSiteInteractor, TipInteractor, TabSessionInteractor,
* ToolbarInteractor, ExperimentCardInteractor, RecentTabInteractor, and RecentBookmarksInteractor. * ToolbarInteractor, ExperimentCardInteractor, RecentTabInteractor, RecentBookmarksInteractor and others.
*/ */
@SuppressWarnings("TooManyFunctions") @SuppressWarnings("TooManyFunctions")
class SessionControlInteractor( class SessionControlInteractor(
private val controller: SessionControlController, private val controller: SessionControlController,
private val recentTabController: RecentTabController, private val recentTabController: RecentTabController,
private val recentBookmarksController: RecentBookmarksController, private val recentBookmarksController: RecentBookmarksController,
private val historyMetadataController: HistoryMetadataController private val historyMetadataController: HistoryMetadataController,
private val pocketStoriesController: PocketStoriesController
) : CollectionInteractor, ) : CollectionInteractor,
OnboardingInteractor, OnboardingInteractor,
TopSiteInteractor, TopSiteInteractor,
@ -234,7 +238,8 @@ class SessionControlInteractor(
RecentTabInteractor, RecentTabInteractor,
RecentBookmarksInteractor, RecentBookmarksInteractor,
HistoryMetadataInteractor, HistoryMetadataInteractor,
CustomizeHomeIteractor { CustomizeHomeIteractor,
PocketStoriesInteractor {
override fun onCollectionAddTabTapped(collection: TabCollection) { override fun onCollectionAddTabTapped(collection: TabCollection) {
controller.handleCollectionAddTabTapped(collection) controller.handleCollectionAddTabTapped(collection)
@ -365,4 +370,8 @@ class SessionControlInteractor(
override fun openCustomizeHomePage() { override fun openCustomizeHomePage() {
controller.handleCustomizeHomeTapped() 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.ExperimentalAnimationApi
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth 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.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Card import androidx.compose.material.Card
import androidx.compose.material.ContentAlpha import androidx.compose.material.ContentAlpha
import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.ExperimentalMaterialApi
@ -33,6 +37,7 @@ import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
import androidx.compose.material.LocalContentAlpha import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider 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.draw.rotate
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity 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.res.painterResource
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString 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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider 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 androidx.compose.ui.unit.dp
import mozilla.components.concept.fetch.Client import mozilla.components.concept.fetch.Client
import mozilla.components.concept.fetch.MutableHeaders 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 * 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. * and also an external link for more up-to-date content.
@ -291,6 +451,14 @@ private fun FinalDesign() {
stories = getFakePocketStories(7), stories = getFakePocketStories(7),
client = FakeClient() 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 package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket
import android.view.View 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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -18,7 +21,8 @@ import mozilla.components.lib.state.ext.observeAsComposableState
import mozilla.components.service.pocket.PocketRecommendedStory import mozilla.components.service.pocket.PocketRecommendedStory
import org.mozilla.fenix.home.HomeFragmentStore 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 * [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 composeView [ComposeView] which will be populated with Jetpack Compose UI content.
* @param store [HomeFragmentStore] containing the list of Pocket stories to be displayed. * @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( class PocketStoriesViewHolder(
val composeView: ComposeView, val composeView: ComposeView,
val store: HomeFragmentStore, val store: HomeFragmentStore,
val client: Client val client: Client,
val interactor: PocketStoriesInteractor
) : RecyclerView.ViewHolder(composeView) { ) : RecyclerView.ViewHolder(composeView) {
init { init {
@ -38,7 +45,7 @@ class PocketStoriesViewHolder(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
) )
composeView.setContent { composeView.setContent {
PocketStories(store, client) PocketStories(store, client) { interactor.onCategoryClick(it) }
} }
} }
@ -50,11 +57,14 @@ class PocketStoriesViewHolder(
@Composable @Composable
fun PocketStories( fun PocketStories(
store: HomeFragmentStore, store: HomeFragmentStore,
client: Client client: Client,
onCategoryClick: (PocketRecommendedStoryCategory) -> Unit
) { ) {
val stories = store val stories = store
.observeAsComposableState { state -> state.pocketStories }.value .observeAsComposableState { state -> state.pocketStories }.value
?.take(STORIES_TO_SHOW_COUNT)
val categories = store
.observeAsComposableState { state -> state.pocketStoriesCategories }.value
ExpandableCard( ExpandableCard(
Modifier Modifier
@ -62,10 +72,15 @@ fun PocketStories(
.padding(top = 40.dp) .padding(top = 40.dp)
) { ) {
PocketRecommendations { PocketRecommendations {
PocketStories( Column {
stories ?: emptyList(), PocketStories(stories ?: emptyList(), client)
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 android.content.Context
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verify
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.TabSessionState
import mozilla.components.concept.storage.BookmarkNode import mozilla.components.concept.storage.BookmarkNode
import mozilla.components.feature.tab.collections.TabCollection import mozilla.components.feature.tab.collections.TabCollection
import mozilla.components.feature.top.sites.TopSite import mozilla.components.feature.top.sites.TopSite
import mozilla.components.service.fxa.manager.FxaAccountManager import mozilla.components.service.fxa.manager.FxaAccountManager
import mozilla.components.service.pocket.PocketRecommendedStory
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.mozilla.fenix.browser.browsingmode.BrowsingMode import org.mozilla.fenix.browser.browsingmode.BrowsingMode
import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager import org.mozilla.fenix.browser.browsingmode.BrowsingModeManager
import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getFilteredStories
import org.mozilla.fenix.historymetadata.HistoryMetadataGroup 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 import org.mozilla.fenix.onboarding.FenixOnboarding
class HomeFragmentStoreTest { class HomeFragmentStoreTest {
@ -179,4 +186,112 @@ class HomeFragmentStoreTest {
assertEquals(2, homeFragmentStore.state.historyMetadata.size) assertEquals(2, homeFragmentStore.state.historyMetadata.size)
assertEquals(Mode.Private, homeFragmentStore.state.mode) 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.recenttabs.controller.RecentTabController
import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController import org.mozilla.fenix.home.sessioncontrol.DefaultSessionControlController
import org.mozilla.fenix.home.sessioncontrol.SessionControlInteractor 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 { class SessionControlInteractorTest {
@ -29,6 +31,7 @@ class SessionControlInteractorTest {
private val recentTabController: RecentTabController = mockk(relaxed = true) private val recentTabController: RecentTabController = mockk(relaxed = true)
private val recentBookmarksController: RecentBookmarksController = mockk(relaxed = true) private val recentBookmarksController: RecentBookmarksController = mockk(relaxed = true)
private val historyMetadataController: HistoryMetadataController = mockk(relaxed = true) private val historyMetadataController: HistoryMetadataController = mockk(relaxed = true)
private val pocketStoriesController: PocketStoriesController = mockk(relaxed = true)
private lateinit var interactor: SessionControlInteractor private lateinit var interactor: SessionControlInteractor
@ -38,7 +41,8 @@ class SessionControlInteractorTest {
controller, controller,
recentTabController, recentTabController,
recentBookmarksController, recentBookmarksController,
historyMetadataController historyMetadataController,
pocketStoriesController
) )
} }
@ -222,4 +226,13 @@ class SessionControlInteractorTest {
interactor.onPrivateModeButtonClicked(newMode, hasBeenOnboarded) interactor.onPrivateModeButtonClicked(newMode, hasBeenOnboarded)
verify { controller.handlePrivateModeButtonClicked(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