/* 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, contentPadding: Dp, onStoryClicked: (PocketRecommendedStory, Pair) -> 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, selections: List, 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 { override val values = getFakePocketStories(7).asSequence() override val count = 8 } internal fun getFakePocketStories(limit: Int = 1): List { return mutableListOf().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() ) ) } } }