From 25817127daec030698e8985273602bd0326a0a30 Mon Sep 17 00:00:00 2001 From: Noah Bond Date: Mon, 14 Feb 2022 14:40:22 -0800 Subject: [PATCH] [fenix] For https://github.com/mozilla-mobile/fenix/issues/21900 - Delete files from Synced Tabs XML implementation --- .../tabstray/FloatingActionButtonBinding.kt | 1 + .../fenix/tabstray/TabsTrayFragment.kt | 18 +- .../mozilla/fenix/tabstray/TabsTrayStore.kt | 9 + .../fenix/tabstray/TrayPagerAdapter.kt | 44 +-- .../fenix/tabstray/syncedtabs/SyncedTabs.kt | 353 ++++++++++++++++++ .../syncedtabs/SyncedTabsIntegration.kt | 122 ++++++ .../tabstray/syncedtabs/SyncedTabsListItem.kt | 61 +++ .../viewholders/SyncedTabsPageViewHolder.kt | 51 ++- app/src/main/res/values/dimens.xml | 3 - app/src/main/res/values/strings.xml | 2 +- ...terKtTest.kt => SyncedTabsListItemTest.kt} | 30 +- .../FloatingActionButtonBindingTest.kt | 4 +- .../tabstray/TabsTrayStoreReducerTest.kt | 15 + 13 files changed, 654 insertions(+), 59 deletions(-) create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabs.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsIntegration.kt create mode 100644 app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsListItem.kt rename app/src/test/java/org/mozilla/fenix/sync/ext/{SyncedTabsAdapterKtTest.kt => SyncedTabsListItemTest.kt} (73%) diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/FloatingActionButtonBinding.kt b/app/src/main/java/org/mozilla/fenix/tabstray/FloatingActionButtonBinding.kt index 3d2b1796e3..d55ba50df4 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/FloatingActionButtonBinding.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/FloatingActionButtonBinding.kt @@ -70,6 +70,7 @@ class FloatingActionButtonBinding( false -> R.string.tab_drawer_fab_sync } ) + contentDescription = context.getString(R.string.resync_button_content_description) extend() show() setIconResource(R.drawable.ic_fab_sync) diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt index 359476668c..06a9af8d7f 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt @@ -61,11 +61,13 @@ import org.mozilla.fenix.tabstray.ext.make import org.mozilla.fenix.tabstray.ext.orDefault import org.mozilla.fenix.tabstray.ext.showWithTheme import org.mozilla.fenix.theme.ThemeManager +import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsIntegration import org.mozilla.fenix.utils.allowUndo import kotlin.math.max @Suppress("TooManyFunctions", "LargeClass") class TabsTrayFragment : AppCompatDialogFragment() { + @VisibleForTesting internal lateinit var tabsTrayStore: TabsTrayStore private lateinit var browserTrayInteractor: BrowserTrayInteractor private lateinit var tabsTrayInteractor: TabsTrayInteractor @@ -82,6 +84,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { private val secureTabsTrayBinding = ViewBoundFeatureWrapper() private val tabsFeature = ViewBoundFeatureWrapper() private val tabsTrayInactiveTabsOnboardingBinding = ViewBoundFeatureWrapper() + private val syncedTabsIntegration = ViewBoundFeatureWrapper() @VisibleForTesting @Suppress("VariableNaming") internal var _tabsTrayBinding: ComponentTabstray2Binding? = null @@ -370,6 +373,19 @@ class TabsTrayFragment : AppCompatDialogFragment() { view = view ) + syncedTabsIntegration.set( + feature = SyncedTabsIntegration( + store = tabsTrayStore, + context = requireContext(), + navController = findNavController(), + storage = requireComponents.backgroundServices.syncedTabsStorage, + accountManager = requireComponents.backgroundServices.accountManager, + lifecycleOwner = this + ), + owner = this, + view = view + ) + setFragmentResultListener(ShareFragment.RESULT_KEY) { _, _ -> dismissTabsTray() } @@ -461,7 +477,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { navigationInteractor, trayInteractor, requireComponents.core.store, - requireComponents.appStore + requireComponents.appStore, ) isUserInputEnabled = false } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayStore.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayStore.kt index 87eea7c7bf..a0b52ac39c 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayStore.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayStore.kt @@ -11,6 +11,7 @@ import mozilla.components.lib.state.Action import mozilla.components.lib.state.Middleware import mozilla.components.lib.state.State import mozilla.components.lib.state.Store +import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsListItem /** * Value type that represents the state of the tabs tray. @@ -32,6 +33,7 @@ data class TabsTrayState( val searchTermPartition: TabPartition? = null, val normalTabs: List = emptyList(), val privateTabs: List = emptyList(), + val syncedTabs: List = emptyList(), val syncing: Boolean = false, val focusGroupTabId: String? = null ) : State { @@ -155,6 +157,11 @@ sealed class TabsTrayAction : Action { * Updates the list of tabs in [TabsTrayState.privateTabs]. */ data class UpdatePrivateTabs(val tabs: List) : TabsTrayAction() + + /** + * Updates the list of synced tabs in [TabsTrayState.syncedTabs]. + */ + data class UpdateSyncedTabs(val tabs: List) : TabsTrayAction() } /** @@ -195,6 +202,8 @@ internal object TabsTrayReducer { state.copy(normalTabs = action.tabs) is TabsTrayAction.UpdatePrivateTabs -> state.copy(privateTabs = action.tabs) + is TabsTrayAction.UpdateSyncedTabs -> + state.copy(syncedTabs = action.tabs) } } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt index 37b242c4e9..3317d408d2 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TrayPagerAdapter.kt @@ -8,18 +8,17 @@ import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup import androidx.annotation.VisibleForTesting +import androidx.compose.ui.platform.ComposeView import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.RecyclerView import mozilla.components.browser.state.store.BrowserStore import org.mozilla.fenix.components.AppStore import org.mozilla.fenix.ext.settings -import org.mozilla.fenix.sync.SyncedTabsAdapter import org.mozilla.fenix.tabstray.browser.BrowserTabsAdapter import org.mozilla.fenix.tabstray.browser.BrowserTrayInteractor -import org.mozilla.fenix.tabstray.browser.TitleHeaderAdapter import org.mozilla.fenix.tabstray.browser.InactiveTabsAdapter import org.mozilla.fenix.tabstray.browser.TabGroupAdapter -import org.mozilla.fenix.tabstray.syncedtabs.TabClickDelegate +import org.mozilla.fenix.tabstray.browser.TitleHeaderAdapter import org.mozilla.fenix.tabstray.viewholders.AbstractPageViewHolder import org.mozilla.fenix.tabstray.viewholders.NormalBrowserPageViewHolder import org.mozilla.fenix.tabstray.viewholders.PrivateBrowserPageViewHolder @@ -57,17 +56,12 @@ class TrayPagerAdapter( TABS_TRAY_FEATURE_NAME ) } - private val syncedTabsAdapter by lazy { - SyncedTabsAdapter(TabClickDelegate(navInteractor)) - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AbstractPageViewHolder { - val itemView = LayoutInflater.from(parent.context).inflate(viewType, parent, false) - return when (viewType) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AbstractPageViewHolder = + when (viewType) { NormalBrowserPageViewHolder.LAYOUT_ID -> { NormalBrowserPageViewHolder( - itemView, + LayoutInflater.from(parent.context).inflate(viewType, parent, false), tabsTrayStore, browserStore, appStore, @@ -76,7 +70,7 @@ class TrayPagerAdapter( } PrivateBrowserPageViewHolder.LAYOUT_ID -> { PrivateBrowserPageViewHolder( - itemView, + LayoutInflater.from(parent.context).inflate(viewType, parent, false), tabsTrayStore, browserStore, interactor @@ -84,22 +78,30 @@ class TrayPagerAdapter( } SyncedTabsPageViewHolder.LAYOUT_ID -> { SyncedTabsPageViewHolder( - itemView, - tabsTrayStore + composeView = ComposeView(parent.context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + }, + tabsTrayStore = tabsTrayStore, + navigationInteractor = navInteractor ) } else -> throw IllegalStateException("Unknown viewType.") } - } + /** + * Until [TrayPagerAdapter] is replaced with a Compose implementation, [SyncedTabsPageViewHolder] + * will need to be called with an empty bind() function since it no longer needs an adapter to render. + * For more details: https://github.com/mozilla-mobile/fenix/issues/21318 + */ override fun onBindViewHolder(viewHolder: AbstractPageViewHolder, position: Int) { - val adapter = when (position) { - POSITION_NORMAL_TABS -> normalAdapter - POSITION_PRIVATE_TABS -> privateAdapter - POSITION_SYNCED_TABS -> syncedTabsAdapter - else -> throw IllegalStateException("View type does not exist.") + when (viewHolder) { + is NormalBrowserPageViewHolder -> viewHolder.bind(normalAdapter) + is PrivateBrowserPageViewHolder -> viewHolder.bind(privateAdapter) + is SyncedTabsPageViewHolder -> viewHolder.bind() } - viewHolder.bind(adapter) } override fun getItemViewType(position: Int): Int { diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabs.kt b/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabs.kt new file mode 100644 index 0000000000..4ab93db57e --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabs.kt @@ -0,0 +1,353 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +@file:Suppress("TooManyFunctions") + +package org.mozilla.fenix.tabstray.syncedtabs + +import android.content.res.Configuration +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.Canvas +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.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import mozilla.components.browser.storage.sync.SyncedDeviceTabs +import mozilla.components.browser.storage.sync.TabEntry +import mozilla.components.browser.toolbar.MAX_URI_LENGTH +import mozilla.components.feature.syncedtabs.view.SyncedTabsView +import org.mozilla.fenix.R +import org.mozilla.fenix.compose.PrimaryText +import org.mozilla.fenix.compose.SecondaryText +import org.mozilla.fenix.theme.FirefoxTheme +import mozilla.components.browser.storage.sync.Tab as SyncTab + +/** + * Top-level list UI for displaying Synced Tabs in the Tabs Tray. + * + * @param syncedTabs The tab UI items to be displayed. + * @param onTabClick The lambda for handling clicks on synced tabs. + */ +@Composable +fun SyncedTabsList(syncedTabs: List, onTabClick: (SyncTab) -> Unit) { + val listState = rememberLazyListState() + + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = listState, + ) { + items(syncedTabs) { syncedTabItem -> + when (syncedTabItem) { + is SyncedTabsListItem.Device -> SyncedTabsDeviceItem(deviceName = syncedTabItem.displayName) + is SyncedTabsListItem.Error -> SyncedTabsErrorItem( + errorText = syncedTabItem.errorText, + errorButton = syncedTabItem.errorButton + ) + is SyncedTabsListItem.NoTabs -> SyncedTabsNoTabsItem() + is SyncedTabsListItem.Tab -> { + SyncedTabsTabItem( + tabTitleText = syncedTabItem.displayTitle, + url = syncedTabItem.displayURL, + ) { + onTabClick(syncedTabItem.tab) + } + } + } + } + + item { + // The Spacer here is to act as a footer to add padding to the bottom of the list so + // the FAB or any potential SnackBar doesn't overlap with the items at the end. + Spacer(Modifier.height(240.dp)) + } + } +} + +/** + * Text header for sections of synced tabs + * + * @param deviceName The name of the user's device connected that has synced tabs. + */ +@Composable +fun SyncedTabsDeviceItem(deviceName: String) { + Column(Modifier.fillMaxWidth()) { + PrimaryText( + text = deviceName, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 16.dp, end = 8.dp, bottom = 8.dp), + fontSize = 14.sp, + fontFamily = FontFamily(Font(R.font.metropolis_semibold)), + maxLines = 1 + ) + + Divider(color = FirefoxTheme.colors.borderPrimary) + } +} + +/** + * Synced tab list item UI + * + * @param tabTitleText The tab's display text. + * @param url The tab's URL. + * @param onClick The click handler when this synced tab is clicked. + */ +@Composable +fun SyncedTabsTabItem(tabTitleText: String, url: String, onClick: () -> Unit) { + Column( + modifier = Modifier + .clickable( + onClickLabel = tabTitleText, + onClick = onClick + ) + .padding(horizontal = 16.dp) + .defaultMinSize(minHeight = 56.dp), + verticalArrangement = Arrangement.Center, + ) { + PrimaryText( + text = tabTitleText, + modifier = Modifier.fillMaxWidth(), + fontSize = 16.sp, + maxLines = 1 + ) + + SecondaryText( + text = url, + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + fontSize = 12.sp, + maxLines = 1 + ) + } +} + +/** + * Error UI to show if there is one of the errors outlined in [SyncedTabsView.ErrorType]. + * + * @param errorText The text to be displayed to the user. + * @param errorButton Optional class to set up and handle any clicks in the Error UI. + */ +@Composable +fun SyncedTabsErrorItem(errorText: String, errorButton: SyncedTabsListItem.ErrorButton? = null) { + Box( + Modifier + .padding(all = 16.dp) + .height(IntrinsicSize.Min) + ) { + val dashColor = FirefoxTheme.colors.borderPrimary + + Canvas(Modifier.fillMaxSize()) { + drawRoundRect( + color = dashColor, + style = Stroke( + width = 2.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(4.dp.toPx(), 4.dp.toPx()), 0f) + ), + cornerRadius = CornerRadius( + x = 8.dp.toPx(), + y = 8.dp.toPx() + ), + ) + } + + Column( + Modifier + .padding(all = 16.dp) + .fillMaxWidth() + ) { + PrimaryText( + text = errorText, + modifier = Modifier.fillMaxWidth(), + fontSize = 14.sp + ) + + errorButton?.let { + Spacer(modifier = Modifier.height(12.dp)) + + SyncedTabsErrorButton(buttonText = it.buttonText, onClick = it.onClick) + } + } + } +} + +/** + * Error button UI within SyncedTabsErrorItem + * + * @param buttonText The error button's text and accessibility hint. + * @param onClick The lambda called when the button is clicked. + */ +@Composable +fun SyncedTabsErrorButton(buttonText: String, onClick: () -> Unit) { + Button( + onClick = onClick, + modifier = Modifier.clip(RoundedCornerShape(size = 4.dp)), + elevation = ButtonDefaults.elevation(defaultElevation = 0.dp, pressedElevation = 0.dp), + colors = ButtonDefaults.outlinedButtonColors(backgroundColor = FirefoxTheme.colors.actionPrimary), + ) { + Icon( + painter = painterResource(R.drawable.ic_sign_in), + contentDescription = null, + tint = FirefoxTheme.colors.textOnColor, + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = buttonText, + modifier = Modifier.align(Alignment.CenterVertically), + color = FirefoxTheme.colors.textOnColor, + fontSize = 14.sp, + fontFamily = FontFamily(Font(R.font.metropolis_semibold)), + maxLines = 2 + ) + } +} + +/** + * UI to be displayed when a user's device has no synced tabs. + */ +@Composable +fun SyncedTabsNoTabsItem() { + SecondaryText( + text = stringResource(R.string.synced_tabs_no_open_tabs), + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth(), + fontSize = 16.sp, + maxLines = 1 + ) +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun SyncedTabsListItemsPreview() { + FirefoxTheme { + Column(Modifier.background(FirefoxTheme.colors.layer1)) { + SyncedTabsDeviceItem(deviceName = "Google Pixel Pro Max +Ultra 5000") + + Spacer(Modifier.height(16.dp)) + + SyncedTabsTabItem(tabTitleText = "Mozilla", url = "www.mozilla.org") { println("Clicked tab") } + + Spacer(Modifier.height(16.dp)) + + SyncedTabsErrorItem(errorText = stringResource(R.string.synced_tabs_reauth)) + + Spacer(Modifier.height(16.dp)) + + SyncedTabsNoTabsItem() + + Spacer(Modifier.height(16.dp)) + } + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun SyncedTabsErrorPreview() { + FirefoxTheme { + Box(Modifier.background(FirefoxTheme.colors.layer1)) { + SyncedTabsErrorItem( + errorText = stringResource(R.string.synced_tabs_no_tabs), + errorButton = SyncedTabsListItem.ErrorButton( + buttonText = stringResource(R.string.synced_tabs_sign_in_button) + ) { + println("SyncedTabsErrorButton click") + }, + ) + } + } +} + +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +private fun SyncedTabsListPreview() { + FirefoxTheme { + Box(Modifier.background(FirefoxTheme.colors.layer1)) { + SyncedTabsList( + getFakeSyncedTabList(), + ) { + println("Tab clicked") + } + } + } +} + +/** + * Converts a list of [SyncedDeviceTabs] into a list of [SyncedTabsListItem]. + */ +fun List.toComposeList() = asSequence().flatMap { (device, tabs) -> + // Transform to sticky headers data here https://github.com/mozilla-mobile/fenix/issues/19942 + val deviceTabs = if (tabs.isEmpty()) { + sequenceOf(SyncedTabsListItem.NoTabs) + } else { + tabs.asSequence().map { + val url = it.active().url + val titleText = it.active().title.ifEmpty { url.take(MAX_URI_LENGTH) } + SyncedTabsListItem.Tab(titleText, url, it) + } + } + + sequenceOf(SyncedTabsListItem.Device(device.displayName)) + deviceTabs +}.toList() + +/** + * Helper function to create a List of [SyncedTabsListItem] for previewing. + */ +@VisibleForTesting internal fun getFakeSyncedTabList(): List = listOf( + SyncedTabsListItem.Device("Device 1"), + generateFakeTab("Mozilla", "www.mozilla.org"), + generateFakeTab("Google", "www.google.com"), + generateFakeTab("", "www.google.com"), + SyncedTabsListItem.Device("Device 2"), + SyncedTabsListItem.NoTabs, + SyncedTabsListItem.Device("Device 3"), + SyncedTabsListItem.Error("Please re-authenticate"), +) + +/** + * Helper function to create a [SyncedTabsListItem.Tab] for previewing. + */ +private fun generateFakeTab(tabName: String, tabUrl: String): SyncedTabsListItem.Tab = SyncedTabsListItem.Tab( + tabName.ifEmpty { tabUrl }, + tabUrl, + SyncTab( + history = listOf(TabEntry(tabName, tabUrl, null)), + active = 0, + lastUsed = 0L, + ) +) diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsIntegration.kt b/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsIntegration.kt new file mode 100644 index 0000000000..ae7b1f1d69 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsIntegration.kt @@ -0,0 +1,122 @@ +/* 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.tabstray.syncedtabs + +import android.content.Context +import androidx.lifecycle.LifecycleOwner +import androidx.navigation.NavController +import mozilla.components.browser.storage.sync.SyncedDeviceTabs +import mozilla.components.feature.syncedtabs.SyncedTabsFeature +import mozilla.components.feature.syncedtabs.storage.SyncedTabsStorage +import mozilla.components.feature.syncedtabs.view.SyncedTabsView +import mozilla.components.service.fxa.manager.FxaAccountManager +import mozilla.components.support.base.feature.LifecycleAwareFeature +import mozilla.components.support.base.observer.Observable +import mozilla.components.support.base.observer.ObserverRegistry +import org.mozilla.fenix.NavGraphDirections +import org.mozilla.fenix.R +import org.mozilla.fenix.tabstray.FloatingActionButtonBinding +import org.mozilla.fenix.tabstray.TabsTrayAction +import org.mozilla.fenix.tabstray.TabsTrayStore + +/** + * TabsTrayFragment delegate to handle all layout updates needed to display synced tabs and any errors. + * + * @param store [TabsTrayStore] + * @param context Fragment context. + * @param navController The controller used to handle any navigation necessary for error scenarios. + * @param storage An instance of [SyncedTabsStorage] used for retrieving synced tabs. + * @param accountManager An instance of [FxaAccountManager] used for synced tabs authentication. + * @param lifecycleOwner View lifecycle owner used to determine when to cancel UI jobs. + */ +class SyncedTabsIntegration( + private val store: TabsTrayStore, + private val context: Context, + private val navController: NavController, + storage: SyncedTabsStorage, + accountManager: FxaAccountManager, + lifecycleOwner: LifecycleOwner, +) : LifecycleAwareFeature, + SyncedTabsView, + Observable by ObserverRegistry() { + + private val syncedTabsFeature by lazy { + SyncedTabsFeature( + context = context, + storage = storage, + accountManager = accountManager, + view = this, + lifecycleOwner = lifecycleOwner, + onTabClicked = { + // We can ignore this callback here because we're not connecting the Compose UI + // back to the feature. + } + ) + } + + private val syncButtonBinding by lazy { + SyncButtonBinding(store) { listener?.onRefresh() } + } + + override var listener: SyncedTabsView.Listener? = null + + override fun start() { + syncedTabsFeature.start() + syncButtonBinding.start() + } + + override fun stop() { + syncedTabsFeature.stop() + syncButtonBinding.stop() + } + + override fun onError(error: SyncedTabsView.ErrorType) { + // We may still be displaying a "loading" spinner, hide it. + stopLoading() + + store.dispatch(TabsTrayAction.UpdateSyncedTabs(listOf(error.toSyncedTabsListItem()))) + } + + /** + * Do nothing; the UI is handled with [FloatingActionButtonBinding]. + */ + override fun startLoading() = Unit + + override fun stopLoading() { + store.dispatch(TabsTrayAction.SyncCompleted) + } + + override fun displaySyncedTabs(syncedTabs: List) { + store.dispatch(TabsTrayAction.UpdateSyncedTabs(syncedTabs.toComposeList())) + } + + /** + * Converts [SyncedTabsView.ErrorType] to [SyncedTabsListItem.Error] with a lambda for ONLY + * [SyncedTabsView.ErrorType.SYNC_UNAVAILABLE] + */ + private fun SyncedTabsView.ErrorType.toSyncedTabsListItem() = when (this) { + SyncedTabsView.ErrorType.MULTIPLE_DEVICES_UNAVAILABLE -> + SyncedTabsListItem.Error(errorText = context.getString(R.string.synced_tabs_connect_another_device)) + + SyncedTabsView.ErrorType.SYNC_ENGINE_UNAVAILABLE -> + SyncedTabsListItem.Error(errorText = context.getString(R.string.synced_tabs_enable_tab_syncing)) + + SyncedTabsView.ErrorType.SYNC_NEEDS_REAUTHENTICATION -> + SyncedTabsListItem.Error(errorText = context.getString(R.string.synced_tabs_sign_in_message)) + + SyncedTabsView.ErrorType.NO_TABS_AVAILABLE -> + SyncedTabsListItem.Error(errorText = context.getString(R.string.synced_tabs_reauth)) + + SyncedTabsView.ErrorType.SYNC_UNAVAILABLE -> + SyncedTabsListItem.Error( + errorText = context.getString(R.string.synced_tabs_no_tabs), + errorButton = SyncedTabsListItem.ErrorButton( + buttonText = context.getString(R.string.synced_tabs_sign_in_button) + ) { + navController.navigate(NavGraphDirections.actionGlobalTurnOnSync()) + }, + ) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsListItem.kt b/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsListItem.kt new file mode 100644 index 0000000000..b50d6ab0ad --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/syncedtabs/SyncedTabsListItem.kt @@ -0,0 +1,61 @@ +/* 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.tabstray.syncedtabs + +import mozilla.components.browser.storage.sync.Tab as SyncTab + +/** + * The various types of list items that can be found in a [SyncedTabsList]. + */ +sealed class SyncedTabsListItem { + + /** + * A device header for displaying a synced device. + * + * @param displayName The user's custom name of their synced device. + */ + data class Device(val displayName: String) : SyncedTabsListItem() + + /** + * A tab that was synced. + * + * @param displayTitle The title of the tab's web page. + * @param displayURL The tab's URL up to BrowserToolbar.MAX_URI_LENGTH characters long. + * @param tab The underlying SyncTab object passed when the tab is clicked. + */ + data class Tab( + val displayTitle: String, + val displayURL: String, + val tab: SyncTab + ) : SyncedTabsListItem() + + /** + * A placeholder for a device that has no tabs synced. + */ + object NoTabs : SyncedTabsListItem() + + /** + * A message displayed if an error was encountered. + * + * @param errorText The text to be displayed to the user. + * @param errorButton Optional class to set up and handle any clicks in the Error UI. + */ + data class Error( + val errorText: String, + val errorButton: ErrorButton? = null, + ) : SyncedTabsListItem() + + /** + * A button displayed if an error has optional interaction. + * + * @param buttonText The error button's text and accessibility hint. + * @param onClick Lambda called when the button is clicked. + * + */ + data class ErrorButton( + val buttonText: String, + val onClick: () -> Unit + ) +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/SyncedTabsPageViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/SyncedTabsPageViewHolder.kt index c695b71109..6a47956906 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/SyncedTabsPageViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/viewholders/SyncedTabsPageViewHolder.kt @@ -5,32 +5,47 @@ package org.mozilla.fenix.tabstray.viewholders import android.view.View -import androidx.recyclerview.widget.GridLayoutManager +import androidx.compose.ui.platform.ComposeView import androidx.recyclerview.widget.RecyclerView -import org.mozilla.fenix.R -import org.mozilla.fenix.databinding.ComponentSyncTabsTrayLayoutBinding +import mozilla.components.lib.state.ext.observeAsComposableState +import org.mozilla.fenix.tabstray.NavigationInteractor import org.mozilla.fenix.tabstray.TabsTrayStore - +import org.mozilla.fenix.tabstray.TabsTrayState +import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsList +import org.mozilla.fenix.theme.FirefoxTheme + +/** + * Temporary ViewHolder to render [SyncedTabsList] until all of the Tabs Tray is written in Compose. + * + * @param composeView Root ComposeView passed-in from TrayPagerAdapter. + * @param tabsTrayStore Store used as a Composable State to listen for changes to [TabsTrayState.syncedTabs]. + * @param navigationInteractor The lambda for handling clicks on synced tabs. + */ class SyncedTabsPageViewHolder( - containerView: View, - private val tabsTrayStore: TabsTrayStore -) : AbstractPageViewHolder(containerView) { - - override fun bind( - adapter: RecyclerView.Adapter - ) { - val binding = ComponentSyncTabsTrayLayoutBinding.bind(containerView) - - binding.syncedTabsList.layoutManager = GridLayoutManager(containerView.context, 1) - binding.syncedTabsList.adapter = adapter - - binding.syncedTabsTrayLayout.tabsTrayStore = tabsTrayStore + private val composeView: ComposeView, + private val tabsTrayStore: TabsTrayStore, + private val navigationInteractor: NavigationInteractor, +) : AbstractPageViewHolder(composeView) { + + fun bind() { + composeView.setContent { + val tabs = tabsTrayStore.observeAsComposableState { state -> state.syncedTabs }.value + FirefoxTheme { + SyncedTabsList( + syncedTabs = tabs ?: emptyList(), + onTabClick = navigationInteractor::onSyncedTabClicked + ) + } + } } + override fun bind(adapter: RecyclerView.Adapter) = Unit // no-op + override fun detachedFromWindow() = Unit // no-op + override fun attachedToWindow() = Unit // no-op companion object { - const val LAYOUT_ID = R.layout.component_sync_tabs_tray_layout + val LAYOUT_ID = View.generateViewId() } } diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 22330ef8d1..f60c44401c 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -176,9 +176,6 @@ 16dp 48dp - - 20dp - 48dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c0752b4aa4..252719ccae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -165,7 +165,7 @@ Install - Synced tabs + Synced tabs Resync diff --git a/app/src/test/java/org/mozilla/fenix/sync/ext/SyncedTabsAdapterKtTest.kt b/app/src/test/java/org/mozilla/fenix/sync/ext/SyncedTabsListItemTest.kt similarity index 73% rename from app/src/test/java/org/mozilla/fenix/sync/ext/SyncedTabsAdapterKtTest.kt rename to app/src/test/java/org/mozilla/fenix/sync/ext/SyncedTabsListItemTest.kt index 905e5bafe6..eb02a85900 100644 --- a/app/src/test/java/org/mozilla/fenix/sync/ext/SyncedTabsAdapterKtTest.kt +++ b/app/src/test/java/org/mozilla/fenix/sync/ext/SyncedTabsListItemTest.kt @@ -13,12 +13,14 @@ import mozilla.components.concept.sync.DeviceType import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test -import org.mozilla.fenix.sync.SyncedTabsAdapter +import org.mozilla.fenix.tabstray.syncedtabs.SyncedTabsListItem +import org.mozilla.fenix.tabstray.syncedtabs.toComposeList -class SyncedTabsAdapterKtTest { +class SyncedTabsListItemTest { private val noTabDevice = SyncedDeviceTabs( device = mockk { every { displayName } returns "Charcoal" + every { id } returns "123" every { deviceType } returns DeviceType.DESKTOP }, tabs = emptyList() @@ -27,6 +29,7 @@ class SyncedTabsAdapterKtTest { private val oneTabDevice = SyncedDeviceTabs( device = mockk { every { displayName } returns "Charcoal" + every { id } returns "1234" every { deviceType } returns DeviceType.DESKTOP }, tabs = listOf( @@ -47,6 +50,7 @@ class SyncedTabsAdapterKtTest { private val twoTabDevice = SyncedDeviceTabs( device = mockk { every { displayName } returns "Emerald" + every { id } returns "12345" every { deviceType } returns DeviceType.MOBILE }, tabs = listOf( @@ -76,25 +80,25 @@ class SyncedTabsAdapterKtTest { ) @Test - fun `verify ordering of adapter items`() { + fun `verify ordering of list items`() { val syncedDeviceList = listOf(oneTabDevice, twoTabDevice) - val adapterData = syncedDeviceList.toAdapterList() + val listData = syncedDeviceList.toComposeList() - assertEquals(5, adapterData.count()) - assertTrue(adapterData[0] is SyncedTabsAdapter.AdapterItem.Device) - assertTrue(adapterData[1] is SyncedTabsAdapter.AdapterItem.Tab) - assertTrue(adapterData[2] is SyncedTabsAdapter.AdapterItem.Device) - assertTrue(adapterData[3] is SyncedTabsAdapter.AdapterItem.Tab) - assertTrue(adapterData[4] is SyncedTabsAdapter.AdapterItem.Tab) + assertEquals(5, listData.count()) + assertTrue(listData[0] is SyncedTabsListItem.Device) + assertTrue(listData[1] is SyncedTabsListItem.Tab) + assertTrue(listData[2] is SyncedTabsListItem.Device) + assertTrue(listData[3] is SyncedTabsListItem.Tab) + assertTrue(listData[4] is SyncedTabsListItem.Tab) } @Test fun `verify no tabs displayed`() { val syncedDeviceList = listOf(noTabDevice) - val adapterData = syncedDeviceList.toAdapterList() + val adapterData = syncedDeviceList.toComposeList() assertEquals(2, adapterData.count()) - assertTrue(adapterData[0] is SyncedTabsAdapter.AdapterItem.Device) - assertTrue(adapterData[1] is SyncedTabsAdapter.AdapterItem.NoTabs) + assertTrue(adapterData[0] is SyncedTabsListItem.Device) + assertTrue(adapterData[1] is SyncedTabsListItem.NoTabs) } } diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/FloatingActionButtonBindingTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/FloatingActionButtonBindingTest.kt index ff742a5e58..87267d33d9 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/FloatingActionButtonBindingTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/FloatingActionButtonBindingTest.kt @@ -122,7 +122,7 @@ class FloatingActionButtonBindingTest { verify(exactly = 0) { actionButton.hide() } verify(exactly = 1) { actionButton.setText(R.string.tab_drawer_fab_sync) } verify(exactly = 1) { actionButton.setIconResource(R.drawable.ic_fab_sync) } - verify(exactly = 2) { actionButton.contentDescription = any() } + verify(exactly = 3) { actionButton.contentDescription = any() } tabsTrayStore.dispatch(TabsTrayAction.SyncNow) tabsTrayStore.waitUntilIdle() @@ -133,6 +133,6 @@ class FloatingActionButtonBindingTest { verify(exactly = 0) { actionButton.hide() } verify(exactly = 1) { actionButton.setText(R.string.sync_syncing_in_progress) } verify(exactly = 2) { actionButton.setIconResource(R.drawable.ic_fab_sync) } - verify(exactly = 2) { actionButton.contentDescription = any() } + verify(exactly = 4) { actionButton.contentDescription = any() } } } diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreReducerTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreReducerTest.kt index 2417522728..211e5fdfe7 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreReducerTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/TabsTrayStoreReducerTest.kt @@ -7,6 +7,7 @@ package org.mozilla.fenix.tabstray import mozilla.components.browser.state.state.createTab import org.junit.Assert.assertEquals import org.junit.Test +import org.mozilla.fenix.tabstray.syncedtabs.getFakeSyncedTabList class TabsTrayStoreReducerTest { @Test @@ -69,4 +70,18 @@ class TabsTrayStoreReducerTest { assertEquals(expectedState, resultState) } + + @Test + fun `WHEN UpdateSyncedTabs THEN synced tabs are added`() { + val syncedTabs = getFakeSyncedTabList() + val initialState = TabsTrayState() + val expectedState = initialState.copy(syncedTabs = syncedTabs) + + val resultState = TabsTrayReducer.reduce( + initialState, + TabsTrayAction.UpdateSyncedTabs(syncedTabs) + ) + + assertEquals(expectedState, resultState) + } }