290 lines
11 KiB
Kotlin
290 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.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 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
|
|
|
|
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 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 = FirefoxTheme.colors.textPrimary,
|
|
fontSize = 12.sp,
|
|
lineHeight = 16.sp
|
|
)
|
|
|
|
ClickableSubstringLink(
|
|
text = text,
|
|
textColor = FirefoxTheme.colors.textPrimary,
|
|
clickableStartIndex = linkStartIndex,
|
|
clickableEndIndex = 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(
|
|
categories = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor"
|
|
.split(" ")
|
|
.map { PocketRecommendedStoriesCategory(it) },
|
|
selections = emptyList(),
|
|
onCategoryClick = {}
|
|
)
|
|
Spacer(Modifier.height(10.dp))
|
|
|
|
PoweredByPocketHeader({})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private class PocketStoryProvider : PreviewParameterProvider<PocketRecommendedStory> {
|
|
override val values = getFakePocketStories(7).asSequence()
|
|
override val count = 8
|
|
}
|
|
|
|
internal fun getFakePocketStories(limit: Int = 1): List<PocketRecommendedStory> {
|
|
return mutableListOf<PocketRecommendedStory>().apply {
|
|
for (index in 0 until limit) {
|
|
add(
|
|
PocketRecommendedStory(
|
|
title = "This is a ${"very ".repeat(index)} long title",
|
|
publisher = "Publisher",
|
|
url = "https://story$index.com",
|
|
imageUrl = "",
|
|
timeToRead = index,
|
|
category = "Category #$index",
|
|
timesShown = index.toLong()
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|