You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
iceraven-browser/app/src/main/java/org/mozilla/fenix/home/pocket/PocketStoriesComposables.kt

295 lines
11 KiB
Kotlin

/* 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/. */
@file:Suppress("MagicNumber")
package org.mozilla.fenix.home.pocket
import android.net.Uri
import androidx.compose.foundation.background
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
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
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.dp
import androidx.compose.ui.unit.sp
import mozilla.components.service.pocket.PocketRecommendedStory
import mozilla.components.ui.colors.PhotonColors
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.ClickableSubstringLink
import org.mozilla.fenix.compose.EagerFlingBehavior
import org.mozilla.fenix.compose.ListItemTabLarge
import org.mozilla.fenix.compose.ListItemTabLargePlaceholder
import org.mozilla.fenix.compose.SelectableChip
import org.mozilla.fenix.compose.StaggeredHorizontalGrid
import org.mozilla.fenix.compose.TabSubtitle
import org.mozilla.fenix.compose.TabSubtitleWithInterdot
import org.mozilla.fenix.compose.TabTitle
import org.mozilla.fenix.theme.FirefoxTheme
import kotlin.math.roundToInt
import kotlin.random.Random
private const val URI_PARAM_UTM_KEY = "utm_source"
private const val POCKET_STORIES_UTM_VALUE = "pocket-newtab-android"
private const val POCKET_FEATURE_UTM_KEY_VALUE = "utm_source=ff_android"
/**
* Placeholder [PocketRecommendedStory] allowing to combine other items in the same list that shows stories.
* It uses empty values for it's properties ensuring that no conflict is possible since real stories have
* mandatory values.
*/
private val placeholderStory = PocketRecommendedStory("", "", "", "", "", 0, 0)
/**
* Displays a single [PocketRecommendedStory].
*
* @param story The [PocketRecommendedStory] to be displayed.
* @param onStoryClick Callback for when the user taps on this story.
*/
@Composable
fun PocketStory(
@PreviewParameter(PocketStoryProvider::class) story: PocketRecommendedStory,
onStoryClick: (PocketRecommendedStory) -> Unit,
) {
val imageUrl = story.imageUrl.replace(
"{wh}",
with(LocalDensity.current) { "${116.dp.toPx().roundToInt()}x${84.dp.toPx().roundToInt()}" }
)
val isValidPublisher = story.publisher.isNotBlank()
val isValidTimeToRead = story.timeToRead >= 0
ListItemTabLarge(
imageUrl = imageUrl,
onClick = { onStoryClick(story) },
title = {
TabTitle(text = story.title, maxLines = 2)
},
subtitle = {
if (isValidPublisher && isValidTimeToRead) {
TabSubtitleWithInterdot(story.publisher, "${story.timeToRead} min")
} else if (isValidPublisher) {
TabSubtitle(story.publisher)
} else if (isValidTimeToRead) {
TabSubtitle("${story.timeToRead} min")
}
}
)
}
/**
* Displays a list of [PocketRecommendedStory]es on 3 by 3 grid.
* If there aren't enough stories to fill all columns placeholders containing an external link
* to go to Pocket for more recommendations are added.
*
* @param stories The list of [PocketRecommendedStory]ies to be displayed. Expect a list with 8 items.
* @param contentPadding Dimension for padding the content after it has been clipped.
* This space will be used for shadows and also content rendering when the list is scrolled.
* @param onStoryClicked Callback for when the user taps on a recommended story.
* @param onDiscoverMoreClicked Callback for when the user taps an element which contains an
*/
@Composable
fun PocketStories(
@PreviewParameter(PocketStoryProvider::class) stories: List<PocketRecommendedStory>,
contentPadding: Dp,
onStoryClicked: (PocketRecommendedStory, Pair<Int, Int>) -> Unit,
onDiscoverMoreClicked: (String) -> Unit
) {
// Show stories in at most 3 rows but on any number of columns depending on the data received.
val maxRowsNo = 3
val storiesToShow = (stories + placeholderStory).chunked(maxRowsNo)
val listState = rememberLazyListState()
val flingBehavior = EagerFlingBehavior(lazyRowState = listState)
LazyRow(
contentPadding = PaddingValues(horizontal = contentPadding),
state = listState,
flingBehavior = flingBehavior,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(storiesToShow) { columnIndex, columnItems ->
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
columnItems.forEachIndexed { rowIndex, story ->
if (story == placeholderStory) {
ListItemTabLargePlaceholder(stringResource(R.string.pocket_stories_placeholder_text)) {
onDiscoverMoreClicked("https://getpocket.com/explore?$POCKET_FEATURE_UTM_KEY_VALUE")
}
} else {
PocketStory(story) {
val uri = Uri.parse(story.url)
.buildUpon()
.appendQueryParameter(URI_PARAM_UTM_KEY, POCKET_STORIES_UTM_VALUE)
.build().toString()
onStoryClicked(it.copy(url = uri), rowIndex to columnIndex)
}
}
}
}
}
}
}
/**
* Displays a list of [PocketRecommendedStoriesCategory]s.
*
* @param categories The categories needed to be displayed.
* @param selections List of categories currently selected.
* @param onCategoryClick Callback for when the user taps a category.
* @param modifier [Modifier] to be applied to the layout.
*/
@Composable
fun PocketStoriesCategories(
categories: List<PocketRecommendedStoriesCategory>,
selections: List<PocketRecommendedStoriesSelectedCategory>,
onCategoryClick: (PocketRecommendedStoriesCategory) -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier = modifier) {
StaggeredHorizontalGrid(
horizontalItemsSpacing = 16.dp,
verticalItemsSpacing = 16.dp
) {
categories.filter { it.name != POCKET_STORIES_DEFAULT_CATEGORY_NAME }.forEach { category ->
SelectableChip(category.name, selections.map { it.name }.contains(category.name)) {
onCategoryClick(category)
}
}
}
}
}
/**
* Pocket feature section title.
* Shows a default text about Pocket and offers a external link to learn more.
*
* @param onLearnMoreClicked Callback invoked when the user clicks the "Learn more" link.
* Contains the full URL for where the user should be navigated to.
* @param modifier [Modifier] to be applied to the layout.
*/
@Composable
fun PoweredByPocketHeader(
onLearnMoreClicked: (String) -> Unit,
modifier: Modifier = Modifier
) {
val color = when (isSystemInDarkTheme()) {
true -> PhotonColors.LightGrey30
false -> PhotonColors.DarkGrey90
}
val link = stringResource(R.string.pocket_stories_feature_learn_more)
val text = stringResource(R.string.pocket_stories_feature_caption, link)
val linkStartIndex = text.indexOf(link)
val linkEndIndex = linkStartIndex + link.length
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
Modifier
.fillMaxWidth()
.semantics(mergeDescendants = true) { },
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.pocket_vector),
contentDescription = null,
// Apply the red tint in code. Otherwise the image is black and white.
tint = Color(0xFFEF4056)
)
Spacer(modifier = Modifier.width(16.dp))
Column {
Text(
text = stringResource(R.string.pocket_stories_feature_title),
color = color,
fontSize = 12.sp,
lineHeight = 16.sp
)
ClickableSubstringLink(text, color, linkStartIndex, linkEndIndex) {
onLearnMoreClicked("https://www.mozilla.org/en-US/firefox/pocket/?$POCKET_FEATURE_UTM_KEY_VALUE")
}
}
}
}
}
@Composable
@Preview
private fun PocketStoriesComposablesPreview() {
FirefoxTheme {
Box(Modifier.background(FirefoxTheme.colors.layer2)) {
Column {
PocketStories(
stories = getFakePocketStories(8),
contentPadding = 0.dp,
onStoryClicked = { _, _ -> },
onDiscoverMoreClicked = { }
)
Spacer(Modifier.height(10.dp))
PocketStoriesCategories(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor".split(" ").map {
PocketRecommendedStoriesCategory(it)
},
emptyList(),
{ }
)
Spacer(Modifier.height(10.dp))
PoweredByPocketHeader({ })
}
}
}
}
private class PocketStoryProvider : PreviewParameterProvider<PocketRecommendedStory> {
override val values = getFakePocketStories(7).asSequence()
override val count = 8
}
private fun getFakePocketStories(limit: Int = 1): 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 #$randomNumber",
timesShown = randomNumber.toLong()
)
)
}
}
}