From 539daadbcc5ee1497f47ac2939d128effbd8d1fc Mon Sep 17 00:00:00 2001 From: rahulsainani Date: Fri, 19 Jan 2024 14:03:33 +0100 Subject: [PATCH] Bug 1875465 - Part 3: Add tab strip ui for tablets --- .../org/mozilla/fenix/browser/BrowserFragment.kt | 37 ++ .../org/mozilla/fenix/browser/tabstrip/TabStrip.kt | 414 +++++++++++++++++++++ .../mozilla/fenix/browser/tabstrip/TabStripCard.kt | 71 ++++ .../fenix/browser/tabstrip/TabStripState.kt | 63 ++++ .../java/org/mozilla/fenix/home/HomeFragment.kt | 29 ++ .../fenix/browser/tabstrip/TabStripStateTest.kt | 244 ++++++++++++ 6 files changed, 858 insertions(+) create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/tabstrip/TabStrip.kt create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/tabstrip/TabStripCard.kt create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/tabstrip/TabStripState.kt create mode 100644 mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/tabstrip/TabStripStateTest.kt diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt index f4faa1c12d12..15872282a2ce 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/BrowserFragment.kt @@ -10,7 +10,9 @@ import android.view.View import android.view.ViewGroup import androidx.annotation.VisibleForTesting import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope @@ -37,7 +39,9 @@ import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import org.mozilla.fenix.GleanMetrics.ReaderMode import org.mozilla.fenix.GleanMetrics.Shopping import org.mozilla.fenix.HomeActivity +import org.mozilla.fenix.NavGraphDirections import org.mozilla.fenix.R +import org.mozilla.fenix.browser.tabstrip.TabStrip import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.appstate.AppAction @@ -48,11 +52,13 @@ import org.mozilla.fenix.ext.nav import org.mozilla.fenix.ext.requireComponents import org.mozilla.fenix.ext.runIfFragmentIsAttached import org.mozilla.fenix.ext.settings +import org.mozilla.fenix.home.HomeFragment import org.mozilla.fenix.nimbus.FxNimbus import org.mozilla.fenix.settings.quicksettings.protections.cookiebanners.getCookieBannerUIMode import org.mozilla.fenix.shopping.DefaultShoppingExperienceFeature import org.mozilla.fenix.shopping.ReviewQualityCheckFeature import org.mozilla.fenix.shortcut.PwaOnboardingObserver +import org.mozilla.fenix.theme.FirefoxTheme import org.mozilla.fenix.theme.ThemeManager import org.mozilla.fenix.translations.TranslationsDialogFragment.Companion.SESSION_ID import org.mozilla.fenix.translations.TranslationsDialogFragment.Companion.TRANSLATION_IN_PROGRESS @@ -87,6 +93,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { val context = requireContext() val components = context.components + initTabStrip() if (context.settings().isSwipeToolbarToSwitchTabsEnabled) { binding.gestureLayout.addGestureListener( @@ -238,6 +245,36 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler { } } + private fun initTabStrip() { + if (!resources.getBoolean(R.bool.tablet)) { + return + } + + binding.tabStripView.isVisible = true + binding.tabStripView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + FirefoxTheme { + TabStrip( + onAddTabClick = { + findNavController().navigate( + NavGraphDirections.actionGlobalHome( + focusOnAddressBar = true, + ), + ) + }, + onLastTabClose = { + findNavController().navigate( + BrowserFragmentDirections.actionGlobalHome(), + ) + }, + onSelectedTabClick = {}, + ) + } + } + } + } + private fun initTranslationsAction(context: Context, view: View) { if (!context.settings().enableTranslations) { return diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/tabstrip/TabStrip.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/tabstrip/TabStrip.kt new file mode 100644 index 000000000000..b7d808fe82f7 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/tabstrip/TabStrip.kt @@ -0,0 +1,414 @@ +/* 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.browser.tabstrip + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.systemGestureExclusion +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Devices +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.coerceIn +import androidx.compose.ui.unit.dp +import mozilla.components.browser.state.action.TabListAction +import mozilla.components.browser.state.state.createTab +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.feature.tabs.TabsUseCases +import mozilla.components.lib.state.ext.observeAsState +import org.mozilla.fenix.R +import org.mozilla.fenix.components.AppStore +import org.mozilla.fenix.components.components +import org.mozilla.fenix.compose.Favicon +import org.mozilla.fenix.theme.FirefoxTheme +import org.mozilla.fenix.theme.Theme + +private val minTabStripItemWidth = 160.dp +private val maxTabStripItemWidth = 280.dp +private val tabStripIconSize = 24.dp +private val spaceBetweenTabs = 4.dp +private val tabStripStartPadding = 8.dp +private val addTabIconSize = 20.dp + +/** + * Top level composable for the tabs strip. + * + * @param onHome Whether or not the tabs strip is in the home screen. + * @param browserStore The [BrowserStore] instance used to observe tabs state. + * @param appStore The [AppStore] instance used to observe browsing mode. + * @param tabsUseCases The [TabsUseCases] instance to perform tab actions. + * @param onAddTabClick Invoked when the add tab button is clicked. + * @param onLastTabClose Invoked when the last remaining open tab is closed. + * @param onSelectedTabClick Invoked when a tab is selected. + */ +@Composable +fun TabStrip( + onHome: Boolean = false, + browserStore: BrowserStore = components.core.store, + appStore: AppStore = components.appStore, + tabsUseCases: TabsUseCases = components.useCases.tabsUseCases, + onAddTabClick: () -> Unit, + onLastTabClose: () -> Unit, + onSelectedTabClick: () -> Unit, +) { + val isPrivateMode by appStore.observeAsState(false) { it.mode.isPrivate } + val state by browserStore.observeAsState(TabStripState.initial) { + it.toTabStripState(isSelectDisabled = onHome, isPrivateMode = isPrivateMode) + } + + TabStripContent( + state = state, + onAddTabClick = onAddTabClick, + onCloseTabClick = { + if (state.tabs.size == 1) { + onLastTabClose() + } + tabsUseCases.removeTab(it) + }, + onSelectedTabClick = { + tabsUseCases.selectTab(it) + onSelectedTabClick() + }, + ) +} + +@Composable +private fun TabStripContent( + state: TabStripState, + onAddTabClick: () -> Unit, + onCloseTabClick: (id: String) -> Unit, + onSelectedTabClick: (id: String) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxSize() + .background(FirefoxTheme.colors.layer1) + .systemGestureExclusion(), + verticalAlignment = Alignment.CenterVertically, + ) { + TabsList( + state = state, + modifier = Modifier.weight(1f, fill = false), + onCloseTabClick = onCloseTabClick, + onSelectedTabClick = onSelectedTabClick, + ) + + IconButton(onClick = onAddTabClick) { + Icon( + painter = painterResource(R.drawable.mozac_ic_plus_24), + modifier = Modifier.size(addTabIconSize), + tint = FirefoxTheme.colors.iconPrimary, + contentDescription = stringResource(R.string.add_tab), + ) + } + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +private fun TabsList( + state: TabStripState, + modifier: Modifier = Modifier, + onCloseTabClick: (id: String) -> Unit, + onSelectedTabClick: (id: String) -> Unit, +) { + BoxWithConstraints(modifier = modifier) { + val listState = rememberLazyListState() + // Calculate the width of each tab item based on available width and the number of tabs and + // taking into account the space between tabs. + val availableWidth = maxWidth - tabStripStartPadding + val tabWidth = (availableWidth / state.tabs.size) - spaceBetweenTabs + + LazyRow( + modifier = Modifier, + state = listState, + contentPadding = PaddingValues(start = tabStripStartPadding), + ) { + items( + items = state.tabs, + key = { it.id }, + ) { itemState -> + TabItem( + state = itemState, + onCloseTabClick = onCloseTabClick, + onSelectedTabClick = onSelectedTabClick, + modifier = Modifier + .padding(end = spaceBetweenTabs) + .animateItemPlacement() + .width( + tabWidth.coerceIn( + minimumValue = minTabStripItemWidth, + maximumValue = maxTabStripItemWidth, + ), + ), + ) + } + } + + if (state.tabs.isNotEmpty()) { + // When a new tab is added, scroll to the end of the list. This is done here instead of + // in onCloseTabClick so this acts on state change which can occur from any other + // place e.g. tabs tray. + LaunchedEffect(state.tabs.last().id) { + listState.scrollToItem(state.tabs.size) + } + + // When a tab is selected, scroll to the selected tab. This is done here instead of + // in onSelectedTabClick so this acts on state change which can occur from any other + // place e.g. tabs tray. + val selectedTab = state.tabs.firstOrNull { it.isSelected } + LaunchedEffect(selectedTab?.id) { + if (selectedTab != null) { + val selectedItemInfo = + listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == selectedTab.id } + + if (listState.isItemPartiallyVisible(selectedItemInfo) || selectedItemInfo == null) { + listState.animateScrollToItem(state.tabs.indexOf(selectedTab)) + } + } + } + } + } +} + +private fun LazyListState.isItemPartiallyVisible(itemInfo: LazyListItemInfo?) = + itemInfo != null && + (itemInfo.offset + itemInfo.size > layoutInfo.viewportEndOffset || itemInfo.offset < 0) + +@Composable +private fun TabItem( + state: TabStripItem, + modifier: Modifier = Modifier, + onCloseTabClick: (id: String) -> Unit, + onSelectedTabClick: (id: String) -> Unit, +) { + TabStripCard( + modifier = modifier.fillMaxSize(), + backgroundColor = + if (state.isPrivate) { + if (state.isSelected) { + FirefoxTheme.colors.layer3 + } else { + FirefoxTheme.colors.layer2 + } + } else { + if (state.isSelected) { + FirefoxTheme.colors.layer2 + } else { + FirefoxTheme.colors.layer3 + } + }, + elevation = if (state.isSelected) { + selectedTabStripCardElevation + } else { + defaultTabStripCardElevation + }, + ) { + Row( + modifier = Modifier + .fillMaxSize() + .clickable { onSelectedTabClick(state.id) }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.weight(1f, fill = false), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(modifier = Modifier.size(8.dp)) + + TabStripIcon(state.url) + + Spacer(modifier = Modifier.size(8.dp)) + + Text( + text = state.title, + color = FirefoxTheme.colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = FirefoxTheme.typography.subtitle2, + ) + } + + IconButton(onClick = { onCloseTabClick(state.id) }) { + Icon( + painter = painterResource(R.drawable.mozac_ic_cross_20), + tint = FirefoxTheme.colors.iconPrimary, + contentDescription = stringResource(R.string.close_tab), + ) + } + } + } +} + +@Composable +private fun TabStripIcon(url: String) { + Box( + modifier = Modifier + .size(tabStripIconSize) + .clip(CircleShape), + contentAlignment = Alignment.Center, + ) { + Favicon( + url = url, + size = tabStripIconSize, + ) + } +} + +private class TabUIStateParameterProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + TabStripState( + listOf( + TabStripItem( + id = "1", + title = "Tab 1", + url = "https://www.mozilla.org", + isPrivate = false, + isSelected = false, + ), + TabStripItem( + id = "2", + title = "Tab 2 with a very long title that should be truncated", + url = "https://www.mozilla.org", + isPrivate = false, + isSelected = false, + ), + TabStripItem( + id = "3", + title = "Selected tab", + url = "https://www.mozilla.org", + isPrivate = false, + isSelected = true, + ), + TabStripItem( + id = "p1", + title = "Private tab 1", + url = "https://www.mozilla.org", + isPrivate = true, + isSelected = false, + ), + TabStripItem( + id = "p2", + title = "Private selected tab", + url = "https://www.mozilla.org", + isPrivate = true, + isSelected = true, + ), + ), + ), + ) +} + +@Preview(device = Devices.TABLET) +@Composable +private fun TabStripPreview( + @PreviewParameter(TabUIStateParameterProvider::class) tabStripState: TabStripState, +) { + FirefoxTheme { + TabStripContentPreview(tabStripState.tabs.filter { !it.isPrivate }) + } +} + +@Preview(device = Devices.TABLET) +@Composable +private fun TabStripPreviewDarkMode( + @PreviewParameter(TabUIStateParameterProvider::class) tabStripState: TabStripState, +) { + FirefoxTheme(theme = Theme.Dark) { + TabStripContentPreview(tabStripState.tabs.filter { !it.isPrivate }) + } +} + +@Preview(device = Devices.TABLET) +@Composable +private fun TabStripPreviewPrivateMode( + @PreviewParameter(TabUIStateParameterProvider::class) tabStripState: TabStripState, +) { + FirefoxTheme(theme = Theme.Private) { + TabStripContentPreview(tabStripState.tabs.filter { it.isPrivate }) + } +} + +@Composable +private fun TabStripContentPreview(tabs: List) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(dimensionResource(id = R.dimen.tab_strip_height)), + contentAlignment = Alignment.Center, + ) { + TabStripContent( + state = TabStripState( + tabs = tabs, + ), + onAddTabClick = {}, + onCloseTabClick = {}, + onSelectedTabClick = {}, + ) + } +} + +@Preview(device = Devices.TABLET) +@Composable +private fun TabStripPreview() { + val browserStore = BrowserStore() + + FirefoxTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .height(dimensionResource(id = R.dimen.tab_strip_height)), + contentAlignment = Alignment.Center, + ) { + TabStrip( + appStore = AppStore(), + browserStore = browserStore, + tabsUseCases = TabsUseCases(browserStore), + onAddTabClick = { + val tab = createTab( + url = "www.example.com", + ) + browserStore.dispatch(TabListAction.AddTabAction(tab)) + }, + onLastTabClose = {}, + onSelectedTabClick = {}, + ) + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/tabstrip/TabStripCard.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/tabstrip/TabStripCard.kt new file mode 100644 index 000000000000..57f9c7b3a407 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/tabstrip/TabStripCard.kt @@ -0,0 +1,71 @@ +/* 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.browser.tabstrip + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.mozilla.fenix.compose.annotation.LightDarkPreview +import org.mozilla.fenix.theme.FirefoxTheme + +private val cardShape = RoundedCornerShape(8.dp) +internal val defaultTabStripCardElevation = 0.dp +internal val selectedTabStripCardElevation = 1.dp + +/** + * Card composable used in Tab Strip items. + * + * @param modifier The modifier to be applied to the card. + * @param backgroundColor The background color of the card. + * @param elevation The elevation of the card. + * @param content The content of the card. + */ +@Composable +fun TabStripCard( + modifier: Modifier = Modifier, + backgroundColor: Color = FirefoxTheme.colors.layer3, + elevation: Dp = defaultTabStripCardElevation, + content: @Composable () -> Unit, +) { + Card( + shape = cardShape, + backgroundColor = backgroundColor, + elevation = elevation, + modifier = modifier, + content = content, + ) +} + +@LightDarkPreview +@Composable +private fun TabStripCardPreview() { + FirefoxTheme { + TabStripCard { + Box( + modifier = Modifier + .height(56.dp) + .width(200.dp) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Tab Strip Card", + color = FirefoxTheme.colors.textPrimary, + style = FirefoxTheme.typography.subtitle1, + ) + } + } + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/tabstrip/TabStripState.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/tabstrip/TabStripState.kt new file mode 100644 index 000000000000..5dacf2b4a4cc --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/browser/tabstrip/TabStripState.kt @@ -0,0 +1,63 @@ +/* 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.browser.tabstrip + +import mozilla.components.browser.state.selector.getNormalOrPrivateTabs +import mozilla.components.browser.state.state.BrowserState + +/** + * The ui state of the tabs strip. + * + * @property tabs The list of [TabStripItem]. + */ +data class TabStripState( + val tabs: List, +) { + companion object { + val initial = TabStripState(tabs = emptyList()) + } +} + +/** + * The ui state of a tab. + * + * @property id The id of the tab. + * @property title The title of the tab. + * @property url The url of the tab. + * @property isPrivate Whether or not the tab is private. + * @property isSelected Whether or not the tab is selected. + */ +data class TabStripItem( + val id: String, + val title: String, + val url: String, + val isPrivate: Boolean, + val isSelected: Boolean, +) + +/** + * Converts [BrowserState] to [TabStripState] that contains the information needed to render the + * tabs strip. + * + * @param isSelectDisabled When true, the tabs will show as selected. + * @param isPrivateMode Whether or not the browser is in private mode. + */ +internal fun BrowserState.toTabStripState( + isSelectDisabled: Boolean, + isPrivateMode: Boolean, +): TabStripState { + return TabStripState( + tabs = getNormalOrPrivateTabs(isPrivateMode) + .map { + TabStripItem( + id = it.id, + title = it.content.title.ifBlank { it.content.url }, + url = it.content.url, + isPrivate = it.content.private, + isSelected = !isSelectDisabled && it.id == selectedTabId, + ) + }, + ) +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt index fccae3ff9973..5f61e7cd70cf 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/home/HomeFragment.kt @@ -23,6 +23,7 @@ import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.testTagsAsResourceId @@ -77,6 +78,7 @@ import mozilla.components.lib.state.ext.consumeFrom import mozilla.components.service.glean.private.NoExtras import mozilla.components.support.base.feature.ViewBoundFeatureWrapper import mozilla.components.ui.colors.PhotonColors +import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.GleanMetrics.HomeScreen import org.mozilla.fenix.GleanMetrics.Homepage import org.mozilla.fenix.GleanMetrics.PrivateBrowsingShortcutCfr @@ -84,6 +86,7 @@ import org.mozilla.fenix.HomeActivity import org.mozilla.fenix.R import org.mozilla.fenix.addons.showSnackBar import org.mozilla.fenix.browser.browsingmode.BrowsingMode +import org.mozilla.fenix.browser.tabstrip.TabStrip import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.PrivateShortcutCreateManager import org.mozilla.fenix.components.TabCollectionStorage @@ -576,6 +579,7 @@ class HomeFragment : Fragment() { ) toolbarView?.build() + initTabStrip() PrivateBrowsingButtonView(binding.privateBrowsingButton, browsingModeManager) { newMode -> sessionControlInteractor.onPrivateModeButtonClicked(newMode) @@ -653,6 +657,31 @@ class HomeFragment : Fragment() { ) } + private fun initTabStrip() { + if (!resources.getBoolean(R.bool.tablet)) { + return + } + + binding.tabStripView.isVisible = true + binding.tabStripView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + FirefoxTheme { + TabStrip( + onHome = true, + onAddTabClick = { + sessionControlInteractor.onNavigateSearch() + }, + onSelectedTabClick = { + (requireActivity() as HomeActivity).openToBrowser(BrowserDirection.FromHome) + }, + onLastTabClose = {}, + ) + } + } + } + } + /** * Method used to listen to search engine name changes and trigger a top sites update accordingly */ diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/tabstrip/TabStripStateTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/tabstrip/TabStripStateTest.kt new file mode 100644 index 000000000000..e6c98b0eff22 --- /dev/null +++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/browser/tabstrip/TabStripStateTest.kt @@ -0,0 +1,244 @@ +package org.mozilla.fenix.browser.tabstrip + +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.createTab +import org.junit.Assert.assertEquals +import org.junit.Test + +class TabStripStateTest { + + @Test + fun `WHEN browser state tabs is empty THEN tabs strip state tabs is empty`() { + val browserState = BrowserState(tabs = emptyList()) + val actual = browserState.toTabStripState(isSelectDisabled = false, isPrivateMode = false) + + val expected = TabStripState(tabs = emptyList()) + + assertEquals(expected, actual) + } + + @Test + fun `WHEN private mode is off THEN tabs strip state tabs should include only non private tabs`() { + val browserState = BrowserState( + tabs = listOf( + createTab( + url = "https://example.com", + title = "Example 1", + private = false, + id = "1", + ), + createTab( + url = "https://example2.com", + title = "Example 2", + private = true, + id = "2", + ), + createTab( + url = "https://example3.com", + title = "Example 3", + private = false, + id = "3", + ), + ), + ) + val actual = browserState.toTabStripState(isSelectDisabled = false, isPrivateMode = false) + + val expected = TabStripState( + tabs = listOf( + TabStripItem( + id = "1", + title = "Example 1", + url = "https://example.com", + isSelected = false, + isPrivate = false, + ), + TabStripItem( + id = "3", + title = "Example 3", + url = "https://example3.com", + isSelected = false, + isPrivate = false, + ), + ), + ) + + assertEquals(expected, actual) + } + + @Test + fun `WHEN private mode is on THEN tabs strip state tabs should include only private tabs`() { + val browserState = BrowserState( + tabs = listOf( + createTab( + url = "https://example.com", + title = "Example", + private = false, + id = "1", + ), + createTab( + url = "https://example2.com", + title = "Private Example", + private = true, + id = "2", + ), + createTab( + url = "https://example3.com", + title = "Example 3", + private = true, + id = "3", + ), + ), + ) + val actual = browserState.toTabStripState(isSelectDisabled = false, isPrivateMode = true) + + val expected = TabStripState( + tabs = listOf( + TabStripItem( + id = "2", + title = "Private Example", + url = "https://example2.com", + isSelected = false, + isPrivate = true, + ), + TabStripItem( + id = "3", + title = "Example 3", + url = "https://example3.com", + isSelected = false, + isPrivate = true, + ), + ), + ) + + assertEquals(expected, actual) + } + + @Test + fun `WHEN isSelectDisabled is false THEN tabs strip state tabs should have a selected tab`() { + val browserState = BrowserState( + tabs = listOf( + createTab( + url = "https://example.com", + title = "Example 1", + private = false, + id = "1", + ), + createTab( + url = "https://example2.com", + title = "Example 2", + private = false, + id = "2", + ), + ), + selectedTabId = "2", + ) + val actual = browserState.toTabStripState(isSelectDisabled = false, isPrivateMode = false) + + val expected = TabStripState( + tabs = listOf( + TabStripItem( + id = "1", + title = "Example 1", + url = "https://example.com", + isSelected = false, + isPrivate = false, + ), + TabStripItem( + id = "2", + title = "Example 2", + url = "https://example2.com", + isSelected = true, + isPrivate = false, + ), + ), + ) + + assertEquals(expected, actual) + } + + @Test + fun `WHEN isSelectDisabled is true THEN tabs strip state tabs should not have a selected tab`() { + val browserState = BrowserState( + tabs = listOf( + createTab( + url = "https://example.com", + title = "Example 1", + private = false, + id = "1", + ), + createTab( + url = "https://example2.com", + title = "Example 2", + private = false, + id = "2", + ), + ), + selectedTabId = "2", + ) + val actual = browserState.toTabStripState(isSelectDisabled = true, isPrivateMode = false) + + val expected = TabStripState( + tabs = listOf( + TabStripItem( + id = "1", + title = "Example 1", + url = "https://example.com", + isSelected = false, + isPrivate = false, + ), + TabStripItem( + id = "2", + title = "Example 2", + url = "https://example2.com", + isSelected = false, + isPrivate = false, + ), + ), + ) + + assertEquals(expected, actual) + } + + @Test + fun `WHEN a tab does not have a title THEN tabs strip should display the url`() { + val browserState = BrowserState( + tabs = listOf( + createTab( + url = "https://example.com", + title = "Example 1", + private = false, + id = "1", + ), + createTab( + url = "https://example2.com", + title = "", + private = false, + id = "2", + ), + ), + selectedTabId = "2", + ) + val actual = browserState.toTabStripState(isSelectDisabled = false, isPrivateMode = false) + + val expected = TabStripState( + tabs = listOf( + TabStripItem( + id = "1", + title = "Example 1", + url = "https://example.com", + isSelected = false, + isPrivate = false, + ), + TabStripItem( + id = "2", + title = "https://example2.com", + url = "https://example2.com", + isSelected = true, + isPrivate = false, + ), + ), + ) + + assertEquals(expected, actual) + } +} -- 2.11.4.GIT