From 0c632dbbdbc78ed283a82a1696c302a974fefe37 Mon Sep 17 00:00:00 2001 From: Mugurell Date: Tue, 28 Sep 2021 23:30:18 +0300 Subject: [PATCH] For #21391 - Final design composables 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. --- .../fenix/compose/ClickableSubstringLink.kt | 97 ++++ .../java/org/mozilla/fenix/compose/Image.kt | 84 +++ .../fenix/compose/ImagesPlaceholder.kt | 87 ++++ .../mozilla/fenix/compose/ListItemTabLarge.kt | 160 ++++++ .../compose/ListItemTabLargePlaceholder.kt | 79 +++ .../mozilla/fenix/compose/SectionHeader.kt | 48 ++ .../mozilla/fenix/compose/SelectableChip.kt | 77 +++ .../fenix/compose/StaggeredHorizontalGrid.kt | 137 +++++ .../org/mozilla/fenix/compose/TabSubtitle.kt | 49 ++ .../fenix/compose/TabSubtitleWithInterdot.kt | 95 ++++ .../org/mozilla/fenix/compose/TabTitle.kt | 51 ++ .../mozilla/fenix/ext/HomeFragmentState.kt | 2 +- .../org/mozilla/fenix/home/HomeFragment.kt | 1 + .../mozilla/fenix/home/HomeFragmentStore.kt | 2 +- .../sessioncontrol/SessionControlAdapter.kt | 1 + .../SessionControlInteractor.kt | 4 + .../pocket/PocketStoriesComposables.kt | 479 ++++-------------- .../pocket/PocketStoriesController.kt | 15 + .../pocket/PocketStoriesInteractor.kt | 7 + .../pocket/PocketStoriesViewHolder.kt | 58 ++- app/src/main/res/drawable/pocket_vector.xml | 15 + app/src/main/res/values/strings.xml | 13 + .../fenix/ext/HomeFragmentStateTest.kt | 10 +- .../home/SessionControlInteractorTest.kt | 9 + .../DefaultPocketStoriesControllerTest.kt | 31 +- 25 files changed, 1212 insertions(+), 399 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/compose/ClickableSubstringLink.kt create mode 100644 app/src/main/java/org/mozilla/fenix/compose/Image.kt create mode 100644 app/src/main/java/org/mozilla/fenix/compose/ImagesPlaceholder.kt create mode 100644 app/src/main/java/org/mozilla/fenix/compose/ListItemTabLarge.kt create mode 100644 app/src/main/java/org/mozilla/fenix/compose/ListItemTabLargePlaceholder.kt create mode 100644 app/src/main/java/org/mozilla/fenix/compose/SectionHeader.kt create mode 100644 app/src/main/java/org/mozilla/fenix/compose/SelectableChip.kt create mode 100644 app/src/main/java/org/mozilla/fenix/compose/StaggeredHorizontalGrid.kt create mode 100644 app/src/main/java/org/mozilla/fenix/compose/TabSubtitle.kt create mode 100644 app/src/main/java/org/mozilla/fenix/compose/TabSubtitleWithInterdot.kt create mode 100644 app/src/main/java/org/mozilla/fenix/compose/TabTitle.kt create mode 100644 app/src/main/res/drawable/pocket_vector.xml diff --git a/app/src/main/java/org/mozilla/fenix/compose/ClickableSubstringLink.kt b/app/src/main/java/org/mozilla/fenix/compose/ClickableSubstringLink.kt new file mode 100644 index 000000000..f8bc4bc2b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/ClickableSubstringLink.kt @@ -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 + ) { } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/Image.kt b/app/src/main/java/org/mozilla/fenix/compose/Image.kt new file mode 100644 index 000000000..46d53ec78 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/Image.kt @@ -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() + ) +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/ImagesPlaceholder.kt b/app/src/main/java/org/mozilla/fenix/compose/ImagesPlaceholder.kt new file mode 100644 index 000000000..e60fb4ef7 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/ImagesPlaceholder.kt @@ -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)) + ) +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/ListItemTabLarge.kt b/app/src/main/java/org/mozilla/fenix/compose/ListItemTabLarge.kt new file mode 100644 index 000000000..c7836cde8 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/ListItemTabLarge.kt @@ -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" + ) { } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/ListItemTabLargePlaceholder.kt b/app/src/main/java/org/mozilla/fenix/compose/ListItemTabLargePlaceholder.kt new file mode 100644 index 000000000..16b912a3b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/ListItemTabLargePlaceholder.kt @@ -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") + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/SectionHeader.kt b/app/src/main/java/org/mozilla/fenix/compose/SectionHeader.kt new file mode 100644 index 000000000..3c273f720 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/SectionHeader.kt @@ -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") +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/SelectableChip.kt b/app/src/main/java/org/mozilla/fenix/compose/SelectableChip.kt new file mode 100644 index 000000000..11876397a --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/SelectableChip.kt @@ -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) { } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/StaggeredHorizontalGrid.kt b/app/src/main/java/org/mozilla/fenix/compose/StaggeredHorizontalGrid.kt new file mode 100644 index 000000000..aca7c8dbd --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/StaggeredHorizontalGrid.kt @@ -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>() + val notYetPlacedItems = items.map { + it.measure(constraints) + }.toMutableList() + + fun getIndexOfNextPlaceableThatFitsRow(available: List, 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() 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)) + } + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/TabSubtitle.kt b/app/src/main/java/org/mozilla/fenix/compose/TabSubtitle.kt new file mode 100644 index 000000000..747c93e79 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/TabSubtitle.kt @@ -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", + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/TabSubtitleWithInterdot.kt b/app/src/main/java/org/mozilla/fenix/compose/TabSubtitleWithInterdot.kt new file mode 100644 index 000000000..9d0de51a2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/TabSubtitleWithInterdot.kt @@ -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", + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/compose/TabTitle.kt b/app/src/main/java/org/mozilla/fenix/compose/TabTitle.kt new file mode 100644 index 000000000..bcca9ebf2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/compose/TabTitle.kt @@ -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 + ) + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt b/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt index ead2a8cf3..ff82c2d24 100644 --- a/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt +++ b/app/src/main/java/org/mozilla/fenix/ext/HomeFragmentState.kt @@ -36,7 +36,7 @@ fun HomeFragmentState.getFilteredStories( } val oldestSortedCategories = currentlySelectedCategories - .sortedBy { it.lastInteractedWithTimestamp } + .sortedByDescending { it.lastInteractedWithTimestamp } val filteredStoriesCount = getFilteredStoriesCount( pocketStoriesCategories, oldestSortedCategories, neededStoriesCount diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index 9175a0dc8..4b238230b 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -344,6 +344,7 @@ class HomeFragment : Fragment() { navController = findNavController() ), pocketStoriesController = DefaultPocketStoriesController( + homeActivity = activity, homeStore = homeFragmentStore ) ) diff --git a/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt b/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt index 21c8e36f6..8f825d96c 100644 --- a/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt +++ b/app/src/main/java/org/mozilla/fenix/home/HomeFragmentStore.kt @@ -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. */ diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt index 1a02ef459..ddacd882d 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlAdapter.kt @@ -219,6 +219,7 @@ class AdapterItemDiffCallback : DiffUtil.ItemCallback() { } } +@Suppress("LongParameterList") class SessionControlAdapter( private val store: HomeFragmentStore, private val interactor: SessionControlInteractor, diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt index f931eaf4c..62704793d 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/SessionControlInteractor.kt @@ -393,4 +393,8 @@ class SessionControlInteractor( override fun onStoriesShown(storiesShown: List) { pocketStoriesController.handleStoriesShown(storiesShown) } + + override fun onExternalLinkClicked(link: String) { + pocketStoriesController.handleExternalLinkClick(link) + } } diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesComposables.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesComposables.kt index e70e7586f..2de2d2488 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesComposables.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesComposables.kt @@ -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, - 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, 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>() - val notYetPlacedItems = items.map { - it.measure(constraints) - }.toMutableList() - - fun getIndexOfNextPlaceableThatFitsRow(available: List, 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() 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 { override val values = getFakePocketStories(7).asSequence() - override val count = 7 + override val count = 8 } private fun getFakePocketStories(limit: Int = 1): List { @@ -487,12 +241,3 @@ private fun getFakePocketStories(limit: Int = 1): List { } } } - -private class FakeClient : Client() { - override fun fetch(request: Request) = Response( - url = request.url, - status = 200, - body = Response.Body.empty(), - headers = MutableHeaders() - ) -} diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesController.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesController.kt index 0d2dee2ab..ea6f25610 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesController.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesController.kt @@ -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) + + /** + * 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) { homeStore.dispatch(HomeFragmentAction.PocketStoriesShown(storiesShown)) } + + override fun handleExternalLinkClick(link: String) { + homeActivity.openToBrowserAndLoad(link, true, BrowserDirection.FromHome) + } } diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesInteractor.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesInteractor.kt index d0e0a0d14..7f4d18a59 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesInteractor.kt @@ -23,4 +23,11 @@ interface PocketStoriesInteractor { * @param storiesShown the new list of [PocketRecommendedStory]es shown to the user. */ fun onStoriesShown(storiesShown: List) + + /** + * Callback for when the user clicks an external link. + * + * @param link URL clicked. + */ + fun onExternalLinkClicked(link: String) } diff --git a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesViewHolder.kt b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesViewHolder.kt index cb8393b5b..e108f08de 100644 --- a/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/PocketStoriesViewHolder.kt @@ -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) -> 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) } } diff --git a/app/src/main/res/drawable/pocket_vector.xml b/app/src/main/res/drawable/pocket_vector.xml new file mode 100644 index 000000000..9c2ad1929 --- /dev/null +++ b/app/src/main/res/drawable/pocket_vector.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index edb80dc45..7948f87ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1909,4 +1909,17 @@ Close + + + Thought provoking stories + + Stories by topic + + Discover more + + Powered by Pocket + + Part of the Firefox family. %s + + Learn more diff --git a/app/src/test/java/org/mozilla/fenix/ext/HomeFragmentStateTest.kt b/app/src/test/java/org/mozilla/fenix/ext/HomeFragmentStateTest.kt index 4e1c743c0..42a9199af 100644 --- a/app/src/test/java/org/mozilla/fenix/ext/HomeFragmentStateTest.kt +++ b/app/src/test/java/org/mozilla/fenix/ext/HomeFragmentStateTest.kt @@ -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. diff --git a/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt index 96369c0c1..4d005c22d 100644 --- a/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/SessionControlInteractorTest.kt @@ -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) } + } } diff --git a/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/DefaultPocketStoriesControllerTest.kt b/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/DefaultPocketStoriesControllerTest.kt index e16582efa..b4105ac86 100644 --- a/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/DefaultPocketStoriesControllerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/sessioncontrol/viewholders/pocket/DefaultPocketStoriesControllerTest.kt @@ -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 = 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) } + } }