Fonts are not exactly following the Figma design but do better suit the overall
design since the other fonts are also not respecting the latest specs.
pull/600/head
Mugurell 3 years ago committed by mergify[bot]
parent 710a94b9ed
commit 08a71a5366

@ -0,0 +1,97 @@
/* 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.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import mozilla.components.ui.colors.PhotonColors
/**
* [Text] containing a substring styled as an URL informing when this is clicked.
*
* @param text Full text that will be displayed
* @param textColor [Color] of the normal text. The URL substring will have a default URL style applied.
* @param clickableStartIndex [text] index at which the URL substring starts.
* @param clickableEndIndex [text] index at which the URL substring ends.
* @param onClick Callback to be invoked only when the URL substring is clicked.
*/
@Composable
fun ClickableSubstringLink(
text: String,
textColor: Color,
clickableStartIndex: Int,
clickableEndIndex: Int,
onClick: () -> Unit
) {
val annotatedText = buildAnnotatedString {
append(text)
addStyle(
SpanStyle(textColor),
start = 0,
end = clickableStartIndex
)
addStyle(
SpanStyle(
textDecoration = TextDecoration.Underline,
color = when (isSystemInDarkTheme()) {
true -> PhotonColors.Violet40
false -> PhotonColors.Violet70
}
),
start = clickableStartIndex,
end = clickableEndIndex
)
addStyle(
SpanStyle(textColor),
start = clickableEndIndex,
end = text.length
)
addStringAnnotation(
tag = "link",
annotation = "",
start = clickableStartIndex,
end = clickableEndIndex
)
}
ClickableText(
text = annotatedText,
onClick = {
annotatedText
.getStringAnnotations("link", it, it)
.firstOrNull()?.let {
onClick()
}
}
)
}
@Composable
@Preview
private fun ClickableSubstringTextPreview() {
val text = "This text contains a link"
Box(modifier = Modifier.background(PhotonColors.White)) {
ClickableSubstringLink(
text,
PhotonColors.DarkGrey90,
text.indexOf("link"),
text.length
) { }
}
}

@ -0,0 +1,84 @@
/* 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.compose
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient
import mozilla.components.concept.fetch.Client
import mozilla.components.concept.fetch.MutableHeaders
import mozilla.components.concept.fetch.Request
import mozilla.components.concept.fetch.Response
import mozilla.components.support.images.compose.loader.ImageLoader
import mozilla.components.support.images.compose.loader.WithImage
/**
* A composable that lays out and draws the image from a given URL while showing a default placeholder
* while that image is downloaded or a default fallback image when downloading failed.
*
* @param client [Client] instance to be used for downloading the image.
* When using [GeckoViewFetchClient] the image will automatically be cached if it has the right headers.
* @param url URL from where the to download the image to be shown.
* @param modifier [Modifier] to be applied to the layout.
* @param private Whether or not this is a private request. Like in private browsing mode,
* private requests will not cache anything on disk and not send any cookies shared with the browser.
* @param targetSize Image size (width and height) the loaded image should be scaled to.
* @param contentDescription Localized text used by accessibility services to describe what this image represents.
* This should always be provided unless this image is used for decorative purposes, and does not represent
* a meaningful action that a user can take.
*/
@Composable
@Suppress("LongParameterList")
fun Image(
client: Client,
url: String,
modifier: Modifier = Modifier,
private: Boolean = false,
targetSize: Dp = 100.dp,
contentDescription: String? = null
) {
ImageLoader(
url = url,
client = client,
private = private,
targetSize = targetSize
) {
WithImage { painter ->
androidx.compose.foundation.Image(
painter = painter,
modifier = modifier,
contentDescription = contentDescription,
)
}
WithDefaultPlaceholder(modifier, contentDescription)
WithDefaultFallback(modifier, contentDescription)
}
}
@Composable
@Preview
private fun ImagePreview() {
Image(
FakeClient(),
"https://mozilla.com",
Modifier.height(100.dp).width(200.dp)
)
}
internal class FakeClient : Client() {
override fun fetch(request: Request) = Response(
url = request.url,
status = 200,
body = Response.Body.empty(),
headers = MutableHeaders()
)
}

@ -0,0 +1,87 @@
/* 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.compose
import androidx.compose.foundation.Image
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import mozilla.components.support.images.compose.loader.Fallback
import mozilla.components.support.images.compose.loader.ImageLoaderScope
import mozilla.components.support.images.compose.loader.Placeholder
import mozilla.components.ui.colors.PhotonColors
/**
* Renders the app default image placeholder while the image is still getting loaded.
*
* @param modifier [Modifier] allowing to control among others the dimensions and shape of the image.
* @param contentDescription Text provided to accessibility services to describe what this image represents.
* Defaults to [null] suited for an image used only for decorative purposes and not to be read by
* accessibility services.
*/
@Composable
internal fun ImageLoaderScope.WithDefaultPlaceholder(
modifier: Modifier,
contentDescription: String? = null
) {
Placeholder {
DefaultImagePlaceholder(modifier, contentDescription)
}
}
/**
* Renders the app default image placeholder if loading the image failed.
*
* @param modifier [Modifier] allowing to control among others the dimensions and shape of the image.
* @param contentDescription Text provided to accessibility services to describe what this image represents.
* Defaults to [null] suited for an image used only for decorative purposes and not to be read by
* accessibility services.
*/
@Composable
internal fun ImageLoaderScope.WithDefaultFallback(
modifier: Modifier,
contentDescription: String? = null
) {
Fallback {
DefaultImagePlaceholder(modifier, contentDescription)
}
}
/**
* Application default image placeholder.
*
* @param modifier [Modifier] allowing to control among others the dimensions and shape of the image.
* @param contentDescription Text provided to accessibility services to describe what this image represents.
* Defaults to [null] suited for an image used only for decorative purposes and not to be read by
* accessibility services.
*/
@Composable
internal fun DefaultImagePlaceholder(
modifier: Modifier,
contentDescription: String? = null
) {
val color = when (isSystemInDarkTheme()) {
true -> PhotonColors.DarkGrey30
false -> PhotonColors.LightGrey30
}
Image(ColorPainter(color), contentDescription, modifier)
}
@Composable
@Preview
private fun DefaultImagePlaceholderPreview() {
DefaultImagePlaceholder(
Modifier
.size(200.dp, 100.dp)
.clip(RoundedCornerShape(8.dp))
)
}

@ -0,0 +1,160 @@
/* 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.compose
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import mozilla.components.browser.engine.gecko.fetch.GeckoViewFetchClient
import mozilla.components.concept.fetch.Client
import org.mozilla.fenix.theme.FirefoxTheme
/**
* Default layout of a large tab shown in a list taking String arguments for title and caption.
* Has the following structure:
* ```
* ---------------------------------------------
* | -------------- Title |
* | | Image | wrapped on |
* | | from | three rows if needed |
* | | imageUrl | |
* | -------------- Optional caption |
* ---------------------------------------------
* ```
*
* @param client [Client] instance to be used for downloading the image.
* When using [GeckoViewFetchClient] the image will automatically be cached if it has the right headers.
* @param imageUrl URL from where the to download a header image of the tab this composable renders.
* @param title Title off the tab this composable renders.
* @param caption Optional caption text.
* @param onClick Optional callback to be invoked when this composable is clicked.
*/
@Composable
fun ListItemTabLarge(
client: Client,
imageUrl: String,
title: String,
caption: String? = null,
onClick: (() -> Unit)? = null
) {
ListItemTabSurface(client, imageUrl, onClick) {
TabTitle(text = title, maxLines = 3)
if (caption != null) {
TabSubtitle(text = caption)
}
}
}
/**
* Default layout of a large tab shown in a list taking composable arguments for title and caption
* allowing as an exception to customize these elements.
* Has the following structure:
* ```
* ---------------------------------------------
* | -------------- -------------------------- |
* | | | | Title | |
* | | Image | | composable | |
* | | from | -------------------------- |
* | | imageUrl | -------------------------- |
* | | | | Optional composable | |
* | -------------- -------------------------- |
* ---------------------------------------------
* ```
*
* @param client [Client] instance to be used for downloading the image.
* When using [GeckoViewFetchClient] the image will automatically be cached if it has the right headers.
* @param imageUrl URL from where the to download a header image of the tab this composable renders.
* @param title Composable rendering the title of the tab this composable represents.
* @param subtitle Optional tab caption composable.
* @param onClick Optional callback to be invoked when this composable is clicked.
*/
@Composable
fun ListItemTabLarge(
client: Client,
imageUrl: String,
onClick: () -> Unit,
title: @Composable () -> Unit,
subtitle: @Composable (() -> Unit)? = null
) {
ListItemTabSurface(client, imageUrl, onClick) {
title()
subtitle?.invoke()
}
}
/**
* Shared default configuration of a ListItemTabLarge Composable.
*
* @param client [Client] instance to be used for downloading the image.
* When using [GeckoViewFetchClient] the image will automatically be cached if it has the right headers.
* @param imageUrl URL from where the to download a header image of the tab this composable renders.
* @param onClick Optional callback to be invoked when this composable is clicked.
* @param tabDetails [Composable] Displayed to the the end of the image. Allows for variation in the item text style.
*/
@Composable
private fun ListItemTabSurface(
client: Client,
imageUrl: String,
onClick: (() -> Unit)? = null,
tabDetails: @Composable () -> Unit
) {
var modifier = Modifier.size(328.dp, 116.dp)
if (onClick != null) modifier = modifier.then(Modifier.clickable { onClick() })
Card(
modifier = modifier,
shape = RoundedCornerShape(8.dp),
backgroundColor = FirefoxTheme.colors.surface,
elevation = 6.dp
) {
Row(
modifier = Modifier.padding(16.dp)
) {
val (imageWidth, imageHeight) = 116.dp to 84.dp
val imageModifier = Modifier
.size(imageWidth, imageHeight)
.clip(RoundedCornerShape(8.dp))
Image(client, imageUrl, imageModifier, false, imageWidth)
Spacer(Modifier.width(16.dp))
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.SpaceBetween
) {
tabDetails()
}
}
}
}
@Composable
@Preview
private fun ListItemTabLargePreview() {
FirefoxTheme {
ListItemTabLarge(
client = FakeClient(),
imageUrl = "",
title = "This is a very long title for a tab but needs to be so for this preview",
caption = "And this is a caption"
) { }
}
}

@ -0,0 +1,79 @@
/* 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.compose
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Card
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.mozilla.fenix.theme.FirefoxTheme
/**
* Placeholder of a [ListItemTabLarge] with the same dimensions but only a centered text.
* Has the following structure:
* ```
* ---------------------------------------------
* | |
* | |
* | Placeholder text |
* | |
* | |
* ---------------------------------------------
* ```
*
* @param text The only [String] that this will display.
* @param onClick Optional callback to be invoked when this composable is clicked.
*/
@Composable
fun ListItemTabLargePlaceholder(
text: String,
onClick: () -> Unit = { }
) {
Card(
modifier = Modifier
.size(328.dp, 116.dp)
.clickable { onClick() },
shape = RoundedCornerShape(8.dp),
backgroundColor = FirefoxTheme.colors.surface,
elevation = 6.dp,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center
) {
Text(
text = text,
color = FirefoxTheme.colors.textPrimary,
textAlign = TextAlign.Center,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = TextStyle(fontSize = 20.sp),
)
}
}
}
@Composable
@Preview
private fun ListItemTabLargePlaceholderPreview() {
FirefoxTheme {
ListItemTabLargePlaceholder(text = "Item placeholder")
}
}

@ -0,0 +1,48 @@
/* 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.compose
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import org.mozilla.fenix.R
import org.mozilla.fenix.theme.FirefoxTheme
/**
* Default layout for the header of a screen section.
*
* @param text [String] to be styled as header and displayed.
* @param modifier [Modifier] to be applied to the [Text].
*/
@Composable
fun SectionHeader(
text: String,
modifier: Modifier = Modifier
) {
Text(
modifier = modifier,
text = text,
style = TextStyle(
fontFamily = FontFamily(Font(R.font.metropolis_semibold)),
fontSize = 20.sp,
lineHeight = 20.sp
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = FirefoxTheme.colors.textPrimary
)
}
@Composable
@Preview
private fun HeadingTextPreview() {
SectionHeader(text = "Section title")
}

@ -0,0 +1,77 @@
/* 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.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.selectable
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import mozilla.components.ui.colors.PhotonColors
import org.mozilla.fenix.theme.FirefoxTheme
/**
* Default layout of a selectable chip.
*
* @param text [String] displayed in this chip. Ideally should only be one word.
* @param isSelected Whether this should be shown as selected.
* @param onClick Callback for when the user taps this.
*/
@Composable
fun SelectableChip(
text: String,
isSelected: Boolean,
onClick: () -> Unit
) {
val contentColor = when (isSystemInDarkTheme()) {
true -> PhotonColors.LightGrey10
false -> if (isSelected) PhotonColors.LightGrey10 else PhotonColors.DarkGrey90
}
@Suppress("MagicNumber")
val backgroundColor = when (isSystemInDarkTheme()) {
true -> if (isSelected) PhotonColors.Violet50 else PhotonColors.DarkGrey50
// Custom color codes matching the Figma design.
false -> if (isSelected) { Color(0xFF312A65) } else { Color(0x1420123A) }
}
Box(
modifier = Modifier
.selectable(isSelected) { onClick() }
.clip(MaterialTheme.shapes.small)
.background(backgroundColor)
.padding(16.dp, 10.dp)
) {
Text(
text = text.capitalize(Locale.current),
style = TextStyle(fontSize = 14.sp),
color = contentColor
)
}
}
@Composable
@Preview
private fun SelectableChipPreview() {
FirefoxTheme {
Box(Modifier.fillMaxSize().background(FirefoxTheme.colors.surface)) {
SelectableChip("Chirp", false) { }
SelectableChip(text = "Chirp", isSelected = true) { }
}
}
}

@ -0,0 +1,137 @@
/* 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.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import org.mozilla.fenix.theme.FirefoxTheme
/**
* 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 [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.Start,
content: @Composable () -> Unit
) {
val currentLayoutDirection = LocalLayoutDirection.current
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 + horizontalItemsSpacingPixels
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 + when (currentLayoutDirection == LayoutDirection.Ltr) {
true -> if (it < itemRow.lastIndex) horizontalItemsSpacingPixels else 0
false -> if (it > 0) horizontalItemsSpacingPixels else 0
}
}
val itemsPositions = IntArray(itemsSizes.size) { 0 }
with(arrangement) {
arrange(constraints.maxWidth, itemsSizes, currentLayoutDirection, itemsPositions)
}
itemRow.forEachIndexed { itemIndex, item ->
item.place(
x = itemsPositions[itemIndex],
y = (rowIndex * item.height) + (rowIndex * verticalItemsSpacingPixels)
)
}
}
}
}
}
@Composable
@Preview
private fun StaggeredHorizontalGridPreview() {
FirefoxTheme {
Box(Modifier.background(FirefoxTheme.colors.surface)) {
StaggeredHorizontalGrid(
horizontalItemsSpacing = 8.dp,
arrangement = Arrangement.Center
) {
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor"
.split(" ")
.forEach {
Text(text = it, color = Color.Red, modifier = Modifier.border(3.dp, Color.Blue))
}
}
}
}
}

@ -0,0 +1,49 @@
/* 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.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.sp
import org.mozilla.fenix.theme.FirefoxTheme
/**
* Default layout for a tab composable caption.
*
* @param text Tab caption.
* @param modifier Optional [Modifier] to be applied to the layout.
*/
@Composable
fun TabSubtitle(
text: String,
modifier: Modifier = Modifier
) {
Text(
modifier = modifier,
maxLines = 1,
text = text,
style = TextStyle(fontSize = 12.sp),
overflow = TextOverflow.Ellipsis,
color = FirefoxTheme.colors.textSecondary
)
}
@Composable
@Preview
private fun TabSubtitlePreview() {
FirefoxTheme {
Box(Modifier.background(FirefoxTheme.colors.surface)) {
TabSubtitle(
"Awesome tab subtitle",
)
}
}
}

@ -0,0 +1,95 @@
/* 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.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.Preview
import org.mozilla.fenix.theme.FirefoxTheme
/**
* Special caption text for a tab layout shown on one line.
*
* This will combine [firstText] with a interdot and then [secondText] ensuring that the second text
* (which is assumed to be smaller) always fills as much space as needed with the [firstText] automatically
* being resized to be smaller with an added ellipsis characters if needed.
*
* Possible results:
* ```
* - when both texts would fit the screen
* ------------------------------------------
* |firstText · secondText |
* ------------------------------------------
*
* - when both text do not fit, second is shown in entirety, first is ellipsised.
* ------------------------------------------
* |longerFirstTextOrSmallSc... · secondText|
* ------------------------------------------
* ```
*
* @param firstText Text shown at the start of the row.
* @param secondText Text shown at the end of the row.
*/
@Composable
fun TabSubtitleWithInterdot(
firstText: String,
secondText: String,
) {
val currentLayoutDirection = LocalLayoutDirection.current
Layout(
content = {
TabSubtitle(text = firstText)
TabSubtitle(text = " \u00b7 ")
TabSubtitle(text = secondText)
}
) { items, constraints ->
// We need to measure from the end to start to ensure the secondItem will always be on screen
// and depending on secondItem's width and interdot's width the firstItem is automatically resized.
val secondItem = items[2].measure(constraints)
val interdot = items[1].measure(
constraints.copy(maxWidth = constraints.maxWidth - secondItem.width)
)
val firstItem = items[0].measure(
constraints.copy(maxWidth = constraints.maxWidth - secondItem.width - interdot.width)
)
layout(constraints.maxWidth, constraints.maxHeight) {
val itemsPositions = IntArray(items.size)
with(Arrangement.Start) {
arrange(
constraints.maxWidth,
intArrayOf(firstItem.width, interdot.width, secondItem.width),
currentLayoutDirection,
itemsPositions
)
}
val placementHeight = constraints.maxHeight - firstItem.height
listOf(firstItem, interdot, secondItem).forEachIndexed { index, item ->
item.place(itemsPositions[index], placementHeight)
}
}
}
}
@Composable
@Preview
private fun TabSubtitleWithInterdotPreview() {
FirefoxTheme {
Box(Modifier.background(FirefoxTheme.colors.surface)) {
TabSubtitleWithInterdot(
firstText = "firstText",
secondText = "secondText",
)
}
}
}

@ -0,0 +1,51 @@
/* 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.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.sp
import org.mozilla.fenix.theme.FirefoxTheme
/**
* Default layout for a tab composable title.
*
* @param text Tab title
* @param maxLines Maximum number of lines for [text] to span, wrapping if necessary.
* If the text exceeds the given number of lines it will be ellipsized.
* @param modifier Optional [Modifier] to be applied to the layout.
*/
@Composable
fun TabTitle(
text: String,
maxLines: Int,
modifier: Modifier = Modifier
) {
Text(
modifier = modifier,
maxLines = maxLines,
text = text,
style = TextStyle(fontSize = 14.sp),
overflow = TextOverflow.Ellipsis,
color = FirefoxTheme.colors.textPrimary
)
}
@Composable
private fun TabTitlePreview() {
FirefoxTheme {
Box(Modifier.background(FirefoxTheme.colors.surface)) {
TabTitle(
"Awesome tab title",
2
)
}
}
}

@ -36,7 +36,7 @@ fun HomeFragmentState.getFilteredStories(
}
val oldestSortedCategories = currentlySelectedCategories
.sortedBy { it.lastInteractedWithTimestamp }
.sortedByDescending { it.lastInteractedWithTimestamp }
val filteredStoriesCount = getFilteredStoriesCount(
pocketStoriesCategories, oldestSortedCategories, neededStoriesCount

@ -344,6 +344,7 @@ class HomeFragment : Fragment() {
navController = findNavController()
),
pocketStoriesController = DefaultPocketStoriesController(
homeActivity = activity,
homeStore = homeFragmentStore
)
)

@ -53,7 +53,7 @@ data class Tab(
* @property recentTabs The list of recent [RecentTab] in the [HomeFragment].
* @property recentBookmarks The list of recently saved [BookmarkNode]s to show on the [HomeFragment].
* @property historyMetadata The list of [HistoryMetadataGroup].
* @property pocketStories Currently shown [PocketRecommendedStory]ies.
* @property pocketStories The list of currently shown [PocketRecommendedStory]s.
* @property pocketStoriesCategories All [PocketRecommendedStory] categories.
* Also serves as an in memory cache of all stories mapped by category allowing for quick stories filtering.
*/

@ -219,6 +219,7 @@ class AdapterItemDiffCallback : DiffUtil.ItemCallback<AdapterItem>() {
}
}
@Suppress("LongParameterList")
class SessionControlAdapter(
private val store: HomeFragmentStore,
private val interactor: SessionControlInteractor,

@ -393,4 +393,8 @@ class SessionControlInteractor(
override fun onStoriesShown(storiesShown: List<PocketRecommendedStory>) {
pocketStoriesController.handleStoriesShown(storiesShown)
}
override fun onExternalLinkClicked(link: String) {
pocketStoriesController.handleExternalLinkClick(link)
}
}

@ -2,188 +2,116 @@
* 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:OptIn(ExperimentalMaterialApi::class, ExperimentalAnimationApi::class)
@file:Suppress("MagicNumber")
package org.mozilla.fenix.home.sessioncontrol.viewholders.pocket
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
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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
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
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
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.LayoutDirection
import androidx.compose.ui.unit.dp
import mozilla.components.concept.fetch.Client
import mozilla.components.concept.fetch.MutableHeaders
import mozilla.components.concept.fetch.Request
import mozilla.components.concept.fetch.Response
import mozilla.components.service.pocket.PocketRecommendedStory
import mozilla.components.support.images.compose.loader.Fallback
import mozilla.components.support.images.compose.loader.ImageLoader
import mozilla.components.support.images.compose.loader.Placeholder
import mozilla.components.support.images.compose.loader.WithImage
import mozilla.components.ui.colors.PhotonColors
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.ClickableSubstringLink
import org.mozilla.fenix.compose.FakeClient
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.TabSubtitleWithInterdot
import org.mozilla.fenix.compose.TabTitle
import org.mozilla.fenix.theme.FirefoxTheme
import kotlin.math.roundToInt
import kotlin.random.Random
/**
* Displays a single [PocketRecommendedStory].
*
* @param story The [PocketRecommendedStory] to be displayed.
* @param client [Client] instance to be used for downloading the story header image.
* @param onStoryClick Callback for when the user taps on this story.
*/
@Composable
fun PocketStory(
@PreviewParameter(PocketStoryProvider::class) story: PocketRecommendedStory,
client: Client,
modifier: Modifier = Modifier
onStoryClick: (PocketRecommendedStory) -> Unit,
) {
Column(
modifier
.size(160.dp, 191.dp)
.clip(RoundedCornerShape(4.dp))
.clickable { /* no-op */ }
) {
Card(
elevation = 6.dp,
shape = RoundedCornerShape(4.dp),
modifier = Modifier.size(160.dp, 87.dp)
) {
ImageLoader(
client = client,
// The endpoint allows us to ask for the optimal resolution image.
url = story.imageUrl.replace(
"{wh}",
with(LocalDensity.current) {
"${160.dp.toPx().roundToInt()}x${87.dp.toPx().roundToInt()}"
}
),
targetSize = 160.dp
) {
WithImage { painter ->
Image(
painter,
modifier = Modifier.size(160.dp, 87.dp),
contentDescription = "${story.title} story image"
)
}
Placeholder {
Box(
Modifier.background(
when (isSystemInDarkTheme()) {
true -> Color(0xFF42414D) // DarkGrey30
false -> PhotonColors.LightGrey30
}
)
)
}
Fallback {
Box(
Modifier.background(
when (isSystemInDarkTheme()) {
true -> Color(0xFF42414D) // DarkGrey30
false -> PhotonColors.LightGrey30
}
)
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(
modifier = Modifier.padding(bottom = 2.dp),
text = story.publisher,
style = MaterialTheme.typography.caption,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
val imageUrl = story.imageUrl.replace(
"{wh}",
with(LocalDensity.current) { "${116.dp.toPx().roundToInt()}x${84.dp.toPx().roundToInt()}" }
)
ListItemTabLarge(
client = client,
imageUrl = imageUrl,
onClick = { onStoryClick(story) },
title = {
TabTitle(text = story.title, maxLines = 3)
},
subtitle = {
TabSubtitleWithInterdot(story.publisher, "${story.timeToRead} min")
}
Text(
text = story.title,
style = MaterialTheme.typography.subtitle1,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
)
}
/**
* Displays a list of [PocketRecommendedStory]es.
* 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 client [Client] instance to be used for downloading the story header image.
* @param onExternalLinkClicked Callback for when the user taps an element which contains an
* external link for where user can go for more recommendations.
*/
@Composable
fun PocketStories(
@PreviewParameter(PocketStoryProvider::class) stories: List<PocketRecommendedStory>,
client: Client
client: Client,
onExternalLinkClicked: (String) -> Unit
) {
// Items will be shown on two rows. Ceil the divide result to show more items on the top row.
val halfStoriesIndex = (stories.size + 1) / 2
// Show stories in a 3 by 3 grid
val gridLength = 3
LazyRow {
itemsIndexed(stories) { index, item ->
if (index < halfStoriesIndex) {
Column(
Modifier.padding(end = if (index == halfStoriesIndex) 0.dp else 8.dp)
) {
PocketStory(item, client)
Spacer(modifier = Modifier.height(24.dp))
itemsIndexed(stories.chunked(gridLength)) { rowIndex, columnItems ->
Column(Modifier.padding(end = if (rowIndex < gridLength - 1) 8.dp else 0.dp)) {
for (index in 0 until gridLength) {
columnItems.getOrNull(index)?.let { story ->
PocketStory(story, client) {
onExternalLinkClicked(story.url)
}
} ?: ListItemTabLargePlaceholder(stringResource(R.string.pocket_stories_placeholder_text)) {
onExternalLinkClicked("http://getpocket.com/explore")
}
stories.getOrNull(halfStoriesIndex + index)?.let {
PocketStory(it, client)
// Add padding between all rows. Not also after the last.
if (index < gridLength - 1) {
Spacer(modifier = Modifier.height(8.dp))
}
}
}
@ -194,278 +122,104 @@ 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.
* @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 {
StaggeredHorizontalGrid(
horizontalItemsSpacing = 16.dp
) {
categories.forEach { category ->
PocketStoryCategory(category) {
onCategoryClick(it)
SelectableChip(category.name, category.isSelected) {
onCategoryClick(category)
}
}
}
}
/**
* Displays an individual [PocketRecommendedStoryCategory].
* Pocket feature section title.
* Shows a default text about Pocket and offers a external link to learn more.
*
* @param category the categories needed to be displayed.
* @param onClick callback for when the user taps this category.
* @param onExternalLinkClicked Callback invoked when the user clicks the "Learn more" link.
* Contains the full URL for where the user should be navigated to.
*/
@Composable
fun PocketStoryCategory(
category: PocketRecommendedStoryCategory,
onClick: (PocketRecommendedStoryCategory) -> Unit
fun PoweredByPocketHeader(
onExternalLinkClicked: (String) -> Unit,
) {
val contentColor = when (category.isSelected) {
true -> Color.Blue
false -> Color.DarkGray
val color = when (isSystemInDarkTheme()) {
true -> PhotonColors.LightGrey30
false -> PhotonColors.DarkGrey90
}
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.
*/
@Composable
fun PocketRecommendations(
content: @Composable (() -> Unit)
) {
val annotatedText = buildAnnotatedString {
val text = "Pocket is part of the Firefox family. "
val link = "Learn more."
val annotationStartIndex = text.length
val annotationEndIndex = annotationStartIndex + link.length
append(text + link)
addStyle(
SpanStyle(textDecoration = TextDecoration.Underline),
start = annotationStartIndex,
end = annotationEndIndex
)
addStringAnnotation(
tag = "link",
annotation = "https://www.mozilla.org/en-US/firefox/pocket/",
start = annotationStartIndex,
end = annotationEndIndex
)
}
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.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
horizontalAlignment = Alignment.CenterHorizontally,
) {
content()
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
ClickableText(
text = annotatedText,
style = MaterialTheme.typography.caption,
onClick = {
annotatedText
.getStringAnnotations("link", it, it)
.firstOrNull()?.let {
println("Learn more clicked! Should now access ${it.item}")
}
}
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)
)
}
}
}
/**
* Displays [content] in an expandable card.
*/
@Composable
fun ExpandableCard(
modifier: Modifier = Modifier,
content: @Composable (() -> Unit)
) {
var isExpanded by remember { mutableStateOf(true) }
val chevronRotationState by animateFloatAsState(targetValue = if (isExpanded) 0f else 180f)
Spacer(modifier = Modifier.width(16.dp))
Card(
modifier = modifier,
shape = RoundedCornerShape(4.dp),
onClick = { isExpanded = !isExpanded }
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.weight(10f),
text = "Trending stories from Pocket",
style = MaterialTheme.typography.h6,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Column {
Text(text = stringResource(R.string.pocket_stories_feature_title), color = color)
IconButton(
onClick = { isExpanded = !isExpanded },
modifier = Modifier.rotate(chevronRotationState)
) {
Icon(
modifier = Modifier.weight(1f),
painter = painterResource(id = R.drawable.ic_chevron_up),
contentDescription = "Expand or collapse Pocket recommended stories",
)
ClickableSubstringLink(text, color, linkStartIndex, linkEndIndex) {
onExternalLinkClicked("https://www.mozilla.org/en-US/firefox/pocket/")
}
}
AnimatedVisibility(visible = isExpanded) {
content()
}
}
}
}
@Composable
@Preview
private fun FinalDesign() {
ExpandableCard {
PocketRecommendations {
PocketStories(
stories = getFakePocketStories(7),
client = FakeClient()
)
private fun PocketStoriesComposablesPreview() {
FirefoxTheme {
Box(Modifier.background(FirefoxTheme.colors.surface)) {
Column {
PocketStories(
stories = getFakePocketStories(8),
client = FakeClient(),
onExternalLinkClicked = { }
)
Spacer(Modifier.height(10.dp))
Spacer(Modifier.height(8.dp))
PocketStoriesCategories(
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor".split(" ").map {
PocketRecommendedStoryCategory(it)
}
) { }
Spacer(Modifier.height(10.dp))
PocketStoriesCategories(
listOf("general", "health", "technology", "food", "career").map {
PocketRecommendedStoryCategory(it)
}
) { }
PoweredByPocketHeader { }
}
}
}
}
private class PocketStoryProvider : PreviewParameterProvider<PocketRecommendedStory> {
override val values = getFakePocketStories(7).asSequence()
override val count = 7
override val count = 8
}
private fun getFakePocketStories(limit: Int = 1): List<PocketRecommendedStory> {
@ -487,12 +241,3 @@ private fun getFakePocketStories(limit: Int = 1): List<PocketRecommendedStory> {
}
}
}
private class FakeClient : Client() {
override fun fetch(request: Request) = Response(
url = request.url,
status = 200,
body = Response.Body.empty(),
headers = MutableHeaders()
)
}

@ -8,6 +8,8 @@ import org.mozilla.fenix.home.HomeFragmentAction
import org.mozilla.fenix.home.HomeFragmentStore
import mozilla.components.lib.state.Store
import mozilla.components.service.pocket.PocketRecommendedStory
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
/**
* Contract for how all user interactions with the Pocket recommended stories feature are to be handled.
@ -26,14 +28,23 @@ interface PocketStoriesController {
* @param storiesShown the new list of [PocketRecommendedStory]es shown to the user.
*/
fun handleStoriesShown(storiesShown: List<PocketRecommendedStory>)
/**
* Callback for when the an external link is clicked.
*
* @param link URL clicked.
*/
fun handleExternalLinkClick(link: String)
}
/**
* Default behavior for handling all user interactions with the Pocket recommended stories feature.
*
* @param homeActivity [HomeActivity] used to open URLs in a new tab.
* @param homeStore [Store] from which to read the current Pocket recommendations and dispatch new actions on.
*/
internal class DefaultPocketStoriesController(
val homeActivity: HomeActivity,
val homeStore: HomeFragmentStore
) : PocketStoriesController {
override fun handleCategoryClick(categoryClicked: PocketRecommendedStoryCategory) {
@ -74,4 +85,8 @@ internal class DefaultPocketStoriesController(
override fun handleStoriesShown(storiesShown: List<PocketRecommendedStory>) {
homeStore.dispatch(HomeFragmentAction.PocketStoriesShown(storiesShown))
}
override fun handleExternalLinkClick(link: String) {
homeActivity.openToBrowserAndLoad(link, true, BrowserDirection.FromHome)
}
}

@ -23,4 +23,11 @@ interface PocketStoriesInteractor {
* @param storiesShown the new list of [PocketRecommendedStory]es shown to the user.
*/
fun onStoriesShown(storiesShown: List<PocketRecommendedStory>)
/**
* Callback for when the user clicks an external link.
*
* @param link URL clicked.
*/
fun onExternalLinkClicked(link: String)
}

@ -15,15 +15,19 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.concept.fetch.Client
import mozilla.components.lib.state.ext.observeAsComposableState
import mozilla.components.service.pocket.PocketRecommendedStory
import org.mozilla.fenix.R
import org.mozilla.fenix.compose.SectionHeader
import org.mozilla.fenix.home.HomeFragmentStore
import org.mozilla.fenix.theme.FirefoxTheme
internal const val POCKET_STORIES_TO_SHOW_COUNT = 7
internal const val POCKET_CATEGORIES_SELECTED_AT_A_TIME_COUNT = 7
internal const val POCKET_STORIES_TO_SHOW_COUNT = 8
internal const val POCKET_CATEGORIES_SELECTED_AT_A_TIME_COUNT = 8
/**
* [RecyclerView.ViewHolder] that will display a list of [PocketRecommendedStory]es
@ -46,7 +50,15 @@ class PocketStoriesViewHolder(
ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed
)
composeView.setContent {
PocketStories(store, client, interactor::onStoriesShown, interactor::onCategoryClick)
FirefoxTheme {
PocketStories(
store,
client,
interactor::onStoriesShown,
interactor::onCategoryClick,
interactor::onExternalLinkClicked
)
}
}
}
@ -60,7 +72,8 @@ fun PocketStories(
store: HomeFragmentStore,
client: Client,
onStoriesShown: (List<PocketRecommendedStory>) -> Unit,
onCategoryClick: (PocketRecommendedStoryCategory) -> Unit
onCategoryClick: (PocketRecommendedStoryCategory) -> Unit,
onExternalLinkClicked: (String) -> Unit
) {
val stories = store
.observeAsComposableState { state -> state.pocketStories }.value
@ -76,21 +89,32 @@ fun PocketStories(
}
}
ExpandableCard(
Modifier
.fillMaxWidth()
.padding(top = 40.dp)
) {
PocketRecommendations {
Column {
PocketStories(stories ?: emptyList(), client)
Column(modifier = Modifier.padding(vertical = 48.dp)) {
SectionHeader(
text = stringResource(R.string.pocket_stories_header),
modifier = Modifier
.fillMaxWidth()
)
Spacer(Modifier.height(8.dp))
Spacer(Modifier.height(17.dp))
PocketStoriesCategories(categories ?: emptyList()) {
onCategoryClick(it)
}
}
PocketStories(stories ?: emptyList(), client, onExternalLinkClicked)
Spacer(Modifier.height(24.dp))
SectionHeader(
text = stringResource(R.string.pocket_stories_categories_header),
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(17.dp))
PocketStoriesCategories(categories ?: emptyList()) {
onCategoryClick(it)
}
Spacer(Modifier.height(24.dp))
PoweredByPocketHeader(onExternalLinkClicked)
}
}

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 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/. -->
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="22dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="22">
<path
android:fillColor="#EF4056"
android:pathData="M2.0993,0C0.7917,0.0852 -0,0.8317 -0,2.1595V10.1111C-0,16.5695 6.4234,21.6451 11.98,21.605C18.228,21.5599 24,16.2839 24,10.1111V2.1595C24,0.8267 23.1583,0.0751 21.8405,0H2.0993ZM7.1599,6.4685L11.98,11.048L16.805,6.4685C18.9645,5.5616 19.9065,8.0267 19.0196,8.7483L12.7365,14.7457C12.5311,14.9411 11.4338,14.9411 11.2284,14.7457L4.9453,8.7482C4.0985,7.9515 5.2008,5.4463 7.1599,6.4685Z" />
</vector>

@ -1909,4 +1909,17 @@
<!-- Content description for privacy content close button -->
<string name="privacy_content_close_button_content_description">Close</string>
<!-- Pocket recommended stories -->
<!-- Header text for a section on the home screen. -->
<string name="pocket_stories_header">Thought provoking stories</string>
<!-- Header text for a section on the home screen. -->
<string name="pocket_stories_categories_header">Stories by topic</string>
<!-- Text of a button allowing users to access an external url for more Pocket recommendations. -->
<string name="pocket_stories_placeholder_text">Discover more</string>
<!-- Title of an app feature. Smaller than a heading.-->
<string name="pocket_stories_feature_title">Powered by Pocket</string>
<!-- Caption which concatenates the text of "pocket_stories_feature_learn_more" -->
<string name="pocket_stories_feature_caption">Part of the Firefox family. %s</string>
<!-- Clickable text for opening an external link for more information about Pocket. -->
<string name="pocket_stories_feature_learn_more">Learn more</string>
</resources>

@ -141,7 +141,7 @@ class HomeFragmentStateTest {
}
@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`() {
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 newest category`() {
val firstSelectedCategory = otherStoriesCategory.copy(lastInteractedWithTimestamp = 0, isSelected = true)
val lastSelectedCategory = anotherStoriesCategory.copy(lastInteractedWithTimestamp = 1, isSelected = true)
val homeState = HomeFragmentState(
@ -153,8 +153,8 @@ class HomeFragmentStateTest {
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)
assertEquals(2, result.filter { it.category == firstSelectedCategory.name }.size)
assertEquals(3, result.filter { it.category == lastSelectedCategory.name }.size)
}
@Test
@ -280,7 +280,7 @@ class HomeFragmentStateTest {
@Test
fun `GIVEN two categories selected with more than needed stories WHEN getFilteredStories is called THEN the results are sorted in the order of least shown`() {
val firstCategory = PocketRecommendedStoryCategory(
"first", getFakePocketStories(3, "first"), true, 222
"first", getFakePocketStories(3, "first"), true, 0
).run {
// Avoid the first item also being the oldest to eliminate a potential bug in code
// that would still get the expected result.
@ -295,7 +295,7 @@ class HomeFragmentStateTest {
)
}
val secondCategory = PocketRecommendedStoryCategory(
"second", getFakePocketStories(3, "second"), true, 0
"second", getFakePocketStories(3, "second"), true, 222
).run {
// Avoid the first item also being the oldest to eliminate a potential bug in code
// that would still get the expected result.

@ -248,4 +248,13 @@ class SessionControlInteractorTest {
verify { pocketStoriesController.handleCategoryClick(clickedCategory) }
}
@Test
fun `GIVEN a PocketStoriesInteractor WHEN an external link is clicked THEN handle it in a PocketStoriesController`() {
val link = "https://www.mozilla.org/en-US/firefox/pocket/"
interactor.onExternalLinkClicked(link)
verify { pocketStoriesController.handleExternalLinkClick(link) }
}
}

@ -9,6 +9,8 @@ import io.mockk.spyk
import io.mockk.verify
import mozilla.components.service.pocket.PocketRecommendedStory
import org.junit.Test
import org.mozilla.fenix.BrowserDirection
import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.home.HomeFragmentAction
import org.mozilla.fenix.home.HomeFragmentState
import org.mozilla.fenix.home.HomeFragmentStore
@ -23,7 +25,7 @@ class DefaultPocketStoriesControllerTest {
HomeFragmentState(pocketStoriesCategories = listOf(category1, category2))
)
)
val controller = DefaultPocketStoriesController(store)
val controller = DefaultPocketStoriesController(mockk(), store)
controller.handleCategoryClick(category1)
verify(exactly = 0) { store.dispatch(HomeFragmentAction.DeselectPocketStoriesCategory(category1.name)) }
@ -33,7 +35,7 @@ class DefaultPocketStoriesControllerTest {
}
@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`() {
fun `GIVEN 8 categories are selected WHEN when a new one is clicked THEN the oldest selected is deselected before selecting the new one`() {
val category1 = PocketRecommendedStoryCategory(
"cat1", emptyList(), isSelected = true, lastInteractedWithTimestamp = 111
)
@ -43,6 +45,7 @@ class DefaultPocketStoriesControllerTest {
val category4 = category1.copy("cat4", lastInteractedWithTimestamp = 444)
val category5 = category1.copy("cat5", lastInteractedWithTimestamp = 555)
val category6 = category1.copy("cat6", lastInteractedWithTimestamp = 678)
val category7 = category1.copy("cat6", lastInteractedWithTimestamp = 890)
val newSelectedCategory = category1.copy(
"newSelectedCategory", isSelected = false, lastInteractedWithTimestamp = 654321
)
@ -50,12 +53,12 @@ class DefaultPocketStoriesControllerTest {
HomeFragmentStore(
HomeFragmentState(
pocketStoriesCategories = listOf(
category1, category2, category3, category4, category5, category6, oldestSelectedCategory
category1, category2, category3, category4, category5, category6, category7, oldestSelectedCategory
)
)
)
)
val controller = DefaultPocketStoriesController(store)
val controller = DefaultPocketStoriesController(mockk(), store)
controller.handleCategoryClick(newSelectedCategory)
@ -64,7 +67,7 @@ class DefaultPocketStoriesControllerTest {
}
@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`() {
fun `GIVEN fewer than 8 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
)
@ -73,6 +76,7 @@ class DefaultPocketStoriesControllerTest {
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
)
@ -80,12 +84,12 @@ class DefaultPocketStoriesControllerTest {
HomeFragmentStore(
HomeFragmentState(
pocketStoriesCategories = listOf(
category1, category2, category3, category4, category5, oldestSelectedCategory
category1, category2, category3, category4, category5, category6, oldestSelectedCategory
)
)
)
)
val controller = DefaultPocketStoriesController(store)
val controller = DefaultPocketStoriesController(mockk(), store)
controller.handleCategoryClick(newSelectedCategory)
@ -96,11 +100,22 @@ class DefaultPocketStoriesControllerTest {
@Test
fun `WHEN new stories are shown THEN update the State`() {
val store = spyk(HomeFragmentStore())
val controller = DefaultPocketStoriesController(store)
val controller = DefaultPocketStoriesController(mockk(), store)
val storiesShown: List<PocketRecommendedStory> = mockk()
controller.handleStoriesShown(storiesShown)
verify { store.dispatch(HomeFragmentAction.PocketStoriesShown(storiesShown)) }
}
@Test
fun `WHEN an external link is clicked then open that using HomeActivity`() {
val link = "https://www.mozilla.org/en-US/firefox/pocket/"
val homeActivity: HomeActivity = mockk(relaxed = true)
val controller = DefaultPocketStoriesController(homeActivity, mockk())
controller.handleExternalLinkClick(link)
verify { homeActivity.openToBrowserAndLoad(link, true, BrowserDirection.FromHome) }
}
}

Loading…
Cancel
Save