From 93f3458dcaa80916206b1941d86d60b212a768d9 Mon Sep 17 00:00:00 2001 From: Mysochenko Yuriy Date: Wed, 6 Mar 2024 16:37:41 +0100 Subject: [PATCH 1/4] add preference to configure new tab behavior --- .../preference/RadioGroupPreference.kt | 221 +++++++++++++++++- .../java/net/waterfox/android/ext/Context.kt | 6 + .../settings/TabsSettingsComposeView.kt | 40 +++- .../android/tabstray/TabsTrayController.kt | 31 ++- .../net/waterfox/android/utils/Settings.kt | 20 ++ app/src/main/res/values/preference_keys.xml | 4 + app/src/main/res/values/strings.xml | 7 + 7 files changed, 316 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/net/waterfox/android/compose/preference/RadioGroupPreference.kt b/app/src/main/java/net/waterfox/android/compose/preference/RadioGroupPreference.kt index 1cfbda465..f609f8869 100644 --- a/app/src/main/java/net/waterfox/android/compose/preference/RadioGroupPreference.kt +++ b/app/src/main/java/net/waterfox/android/compose/preference/RadioGroupPreference.kt @@ -5,29 +5,56 @@ package net.waterfox.android.compose.preference import android.content.res.Configuration +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.RadioButton import androidx.compose.material.RadioButtonDefaults import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.TextFieldDefaults.indicatorLine 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.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color.Companion.Green +import androidx.compose.ui.graphics.Color.Companion.Red +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTag import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import net.waterfox.android.ext.readBooleanPreference import net.waterfox.android.ext.writeBooleanPreference import net.waterfox.android.theme.Theme import net.waterfox.android.theme.WaterfoxTheme +import net.waterfox.android.R +import net.waterfox.android.ext.readStringPreference +import net.waterfox.android.ext.writeStringPreference @Composable fun RadioGroupPreference( @@ -41,17 +68,33 @@ fun RadioGroupPreference( } return Column { + val onValueChange: (Boolean, RadioGroupItem) -> Unit = { _, item -> + items.forEach { + context.writeBooleanPreference( + it.key, + it.key == item.key, + ) + } + item.onClick?.invoke() + setSelected(item) + } items.forEach { item -> if (item.visible) { - RadioButtonPreference( - title = item.title, - selected = selected?.key == item.key, - onValueChange = { - items.forEach { context.writeBooleanPreference(it.key, it.key == item.key) } - item.onClick?.invoke() - setSelected(item) - }, - ) + if (item.editable) { + val key = stringResource(R.string.pref_key_new_tab_web_address_value) + RadioButtonWithInputPreference( + value = context.readStringPreference(key, "")!!, + selected = selected?.key == item.key, + onValueChange = { onValueChange(it, item) }, + onInputValueChange = { context.writeStringPreference(key, it) }, + ) + } else { + RadioButtonPreference( + title = item.title, + selected = selected?.key == item.key, + onValueChange = { onValueChange(it, item) }, + ) + } } } } @@ -62,6 +105,7 @@ data class RadioGroupItem( val key: String, val defaultValue: Boolean, val visible: Boolean = true, + val editable: Boolean = false, val onClick: (() -> Unit)? = null, ) @@ -140,3 +184,162 @@ private fun RadioButtonPreferenceOffPreview() { ) } } + + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun RadioButtonWithInputPreference( + value: String, + selected: Boolean, + onValueChange: (Boolean) -> Unit, + onInputValueChange: (String) -> Unit, + enabled: Boolean = true, +) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + return Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = selected, + onValueChange = { newValue -> + if (enabled) { + onValueChange(newValue) + } + }, + role = Role.RadioButton, + ) + .alpha(if (enabled) 1f else 0.5f) + .semantics { + testTagsAsResourceId = true + testTag = "radio.button.preference" + }, + ) { + RadioButton( + selected = selected, + onClick = null, + modifier = Modifier + .size(48.dp) + .padding(start = 16.dp), + colors = RadioButtonDefaults.colors( + selectedColor = WaterfoxTheme.colors.formSelected, + unselectedColor = WaterfoxTheme.colors.formDefault, + ), + ) + + TextField( + text = value, + onValueChange = { + onInputValueChange(it) + keyboardController?.hide() + focusManager.clearFocus() + }, + selected = selected, + modifier = Modifier + .align(Alignment.CenterVertically), + ) + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun TextField( + text: String, + onValueChange: (String) -> Unit, + selected: Boolean, + modifier: Modifier = Modifier, +) { + var value by remember { mutableStateOf(text) } + val interactionSource = remember { MutableInteractionSource() } + val customTextSelectionColors = TextSelectionColors( + handleColor = WaterfoxTheme.colors.formSelected, + backgroundColor = WaterfoxTheme.colors.formSelected.copy(alpha = 0.4f), + ) + CompositionLocalProvider(LocalTextSelectionColors provides customTextSelectionColors) { + BasicTextField( + value = value, + onValueChange = { value = it }, + modifier = modifier + .fillMaxWidth() + .padding( + start = 24.dp, + end = 16.dp, + ) + .indicatorLine( + enabled = selected, + false, + interactionSource, + TextFieldDefaults.textFieldColors( + unfocusedIndicatorColor = WaterfoxTheme.colors.formDisabled, + focusedIndicatorColor = WaterfoxTheme.colors.formSelected, + ), + ), + singleLine = true, + enabled = selected, + textStyle = WaterfoxTheme.typography.subtitle1.merge( + TextStyle(color = WaterfoxTheme.colors.textPrimary), + ), + cursorBrush = SolidColor(WaterfoxTheme.colors.formSelected), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Done, + capitalization = KeyboardCapitalization.None, + ), + keyboardActions = KeyboardActions(onDone = { onValueChange(value) }), + ) { innerTextField -> + TextFieldDefaults.TextFieldDecorationBox( + value = value, + visualTransformation = VisualTransformation.None, + innerTextField = innerTextField, + placeholder = { + Text( + text = stringResource(id = R.string.preferences_open_new_tab_web_address), + modifier = Modifier.fillMaxWidth(), + color = WaterfoxTheme.colors.textSecondary, + style = WaterfoxTheme.typography.subtitle1, + ) + }, + singleLine = true, + enabled = selected, + interactionSource = interactionSource, + contentPadding = TextFieldDefaults.textFieldWithoutLabelPadding( + start = 0.dp, end = 0.dp, + ), + colors = TextFieldDefaults.textFieldColors( + textColor = WaterfoxTheme.colors.textPrimary, + cursorColor = WaterfoxTheme.colors.formSelected, + ), + ) + } + } +} + + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun RadioButtonWithInputPreferenceOnPreview() { + WaterfoxTheme(theme = Theme.getTheme()) { + RadioButtonWithInputPreference( + value = "example.com", + selected = true, + onValueChange = {}, + onInputValueChange = {}, + enabled = true, + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true) +@Composable +private fun RadioButtonWithInputPreferenceOffPreview() { + WaterfoxTheme(theme = Theme.getTheme()) { + RadioButtonWithInputPreference( + value = "example.com", + selected = false, + onValueChange = {}, + onInputValueChange = {}, + enabled = true, + ) + } +} diff --git a/app/src/main/java/net/waterfox/android/ext/Context.kt b/app/src/main/java/net/waterfox/android/ext/Context.kt index eff33aec6..3cf679187 100644 --- a/app/src/main/java/net/waterfox/android/ext/Context.kt +++ b/app/src/main/java/net/waterfox/android/ext/Context.kt @@ -51,6 +51,12 @@ fun Context.readFloatPreference(key: String, defaultValue: Float) = fun Context.writeFloatPreference(key: String, value: Float) = settings().preferences.edit().putFloat(key, value).apply() +fun Context.readStringPreference(key: String, defaultValue: String) = + settings().preferences.getString(key, defaultValue) + +fun Context.writeStringPreference(key: String, value: String) = + settings().preferences.edit().putString(key, value).apply() + /** * Gets the Root View with an activity context * diff --git a/app/src/main/java/net/waterfox/android/settings/TabsSettingsComposeView.kt b/app/src/main/java/net/waterfox/android/settings/TabsSettingsComposeView.kt index 511106f60..e752df4ac 100644 --- a/app/src/main/java/net/waterfox/android/settings/TabsSettingsComposeView.kt +++ b/app/src/main/java/net/waterfox/android/settings/TabsSettingsComposeView.kt @@ -7,10 +7,15 @@ package net.waterfox.android.settings import android.content.Context import android.util.AttributeSet import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.AbstractComposeView import androidx.compose.ui.res.stringResource import net.waterfox.android.R @@ -35,7 +40,12 @@ class TabsSettingsComposeView @JvmOverloads constructor( @Composable override fun Content() { WaterfoxTheme { - Column { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .imePadding(), + ) { PreferenceCategory( title = stringResource(R.string.preferences_tab_view), allowDividerAbove = false, @@ -98,6 +108,34 @@ class TabsSettingsComposeView @JvmOverloads constructor( enabled = inactiveTabsCategoryEnabled, ) } + + PreferenceCategory( + title = stringResource(id = R.string.preferences_open_new_tab), + ) { + RadioGroupPreference( + items = listOf( + RadioGroupItem( + title = stringResource(id = R.string.preferences_open_new_tab_show_home), + key = stringResource(R.string.pref_key_new_tab_show_home), + defaultValue = true, + onClick = {}, + ), + RadioGroupItem( + title = stringResource(id = R.string.preferences_open_new_tab_blank_tab), + key = stringResource(R.string.pref_key_new_tab_blank), + defaultValue = false, + onClick = {}, + ), + RadioGroupItem( + title = "", + key = stringResource(R.string.pref_key_new_tab_web_address), + defaultValue = false, + editable = true, + onClick = {}, + ), + ), + ) + } } } } diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayController.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayController.kt index 791c11556..e5f924c9e 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayController.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayController.kt @@ -21,6 +21,7 @@ import net.waterfox.android.R import net.waterfox.android.browser.browsingmode.BrowsingMode import net.waterfox.android.browser.browsingmode.BrowsingModeManager import net.waterfox.android.ext.DEFAULT_ACTIVE_DAYS +import net.waterfox.android.ext.settings import net.waterfox.android.home.HomeFragment import net.waterfox.android.tabstray.ext.isActiveDownload import java.util.concurrent.TimeUnit @@ -123,9 +124,33 @@ class DefaultTabsTrayController( override fun handleOpeningNewTab(isPrivate: Boolean) { val startTime = profiler?.getProfilerTime() browsingModeManager.mode = BrowsingMode.fromBoolean(isPrivate) - navController.navigate( - TabsTrayFragmentDirections.actionGlobalHome(focusOnAddressBar = true) - ) + + val settings = navController.context.settings() + if (settings.openTabShowHome) { + navController.navigate( + TabsTrayFragmentDirections.actionGlobalHome(focusOnAddressBar = true), + ) + } else { + val url = if (settings.openTabShowBlank) { + "about:blank" + } else { + val address = settings.openTabShowWebAddressValue + if (address.startsWith("http")) { + address + } else { + "https://$address" + } + } + val tab = tabsUseCases.addTab( + url, + selectTab = false, + startLoading = true, + parentId = null, + contextId = null, + ) + tabsUseCases.selectTab(tab) + } + navigationInteractor.onTabTrayDismissed() profiler?.addMarker( "DefaultTabTrayController.onNewTabTapped", diff --git a/app/src/main/java/net/waterfox/android/utils/Settings.kt b/app/src/main/java/net/waterfox/android/utils/Settings.kt index 1fcef0c9a..bcbdf008a 100644 --- a/app/src/main/java/net/waterfox/android/utils/Settings.kt +++ b/app/src/main/java/net/waterfox/android/utils/Settings.kt @@ -278,6 +278,26 @@ class Settings(private val appContext: Context) : PreferencesHolder { default = false ) + var openTabShowHome by booleanPreference( + appContext.getPreferenceKey(R.string.pref_key_new_tab_show_home), + default = true + ) + + var openTabShowBlank by booleanPreference( + appContext.getPreferenceKey(R.string.pref_key_new_tab_blank), + default = false + ) + + var openTabShowWebAddress by booleanPreference( + appContext.getPreferenceKey(R.string.pref_key_new_tab_web_address), + default = true + ) + + var openTabShowWebAddressValue by stringPreference( + appContext.getPreferenceKey(R.string.pref_key_new_tab_web_address_value), + default = "" + ) + var allowThirdPartyRootCerts by booleanPreference( appContext.getPreferenceKey(R.string.pref_key_allow_third_party_root_certs), default = false diff --git a/app/src/main/res/values/preference_keys.xml b/app/src/main/res/values/preference_keys.xml index 2523e9360..d1af9a45c 100644 --- a/app/src/main/res/values/preference_keys.xml +++ b/app/src/main/res/values/preference_keys.xml @@ -263,6 +263,10 @@ pref_key_camera_permissions_needed pref_key_inactive_tabs_category pref_key_inactive_tabs + pref_key_new_tab_show_home + pref_key_new_tab_blank + pref_key_new_tab_web_address + pref_key_new_tab_web_address_value pref_key_return_to_browser diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85f0f8425..9e8bb9f61 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -662,6 +662,13 @@ Tabs you haven’t viewed for two weeks get moved to the inactive section. + + + Open new tab + Show home + Blank tab + Enter web address + Open tabs From 477e9866148c5bd1ffbd7b6b36150c194b699f04 Mon Sep 17 00:00:00 2001 From: Yuriy Mysochenko Date: Mon, 11 Mar 2024 17:25:11 +0100 Subject: [PATCH 2/4] Implement tabs reordering. --- .../components/bookmarks/BookmarksUseCase.kt | 23 +- .../net/waterfox/android/compose/Banner.kt | 83 ++ .../android/compose/BottomSheetHandle.kt | 74 ++ .../android/compose/SwipeToDismiss.kt | 169 ++++ .../waterfox/android/compose/TabCounter.kt | 136 +++ .../compose/annotation/LightDarkPreview.kt | 16 + .../compose/button/FloatingActionButton.kt | 105 +++ .../net/waterfox/android/compose/ext/Int.kt | 15 + .../tabstray/DismissedTabBackground.kt | 112 +++ .../android/compose/tabstray/MediaImage.kt | 19 +- .../android/compose/tabstray/TabGridItem.kt | 270 ++++-- .../android/compose/tabstray/TabListItem.kt | 218 +++-- .../android/tabstray/CloseOnLastTabBinding.kt | 2 +- .../tabstray/FloatingActionButtonBinding.kt | 46 +- .../android/tabstray/MenuIntegration.kt | 15 +- .../android/tabstray/NavigationInteractor.kt | 102 +-- .../android/tabstray/SecureTabsTrayBinding.kt | 5 +- .../android/tabstray/SyncedTabsController.kt | 19 + .../android/tabstray/SyncedTabsInteractor.kt | 19 + .../android/tabstray/TabCounterBinding.kt | 2 +- .../tabstray/TabSheetBehaviorManager.kt | 98 ++- .../net/waterfox/android/tabstray/TabsTray.kt | 630 +++++++++++++ .../android/tabstray/TabsTrayBanner.kt | 487 +++++++++++ .../android/tabstray/TabsTrayController.kt | 366 ++++++-- .../android/tabstray/TabsTrayDialog.kt | 6 +- .../waterfox/android/tabstray/TabsTrayFab.kt | 131 +++ .../android/tabstray/TabsTrayFragment.kt | 649 ++++++++------ .../TabsTrayInactiveTabsOnboardingBinding.kt | 4 +- .../tabstray/TabsTrayInfoBannerBinding.kt | 6 +- .../android/tabstray/TabsTrayInteractor.kt | 209 ++++- .../waterfox/android/tabstray/TabsTrayMenu.kt | 16 +- .../android/tabstray/TabsTrayMiddleware.kt | 36 - .../android/tabstray/TabsTrayStore.kt | 11 +- .../android/tabstray/TabsTrayTabLayouts.kt | 463 ++++++++++ .../android/tabstray/TabsTrayTestTag.kt | 47 + .../android/tabstray/TrayPagerAdapter.kt | 41 +- .../browser/AbstractBrowserTabViewHolder.kt | 43 +- .../browser/AbstractBrowserTrayList.kt | 11 +- .../tabstray/browser/BrowserTabViewHolder.kt | 122 +++ .../tabstray/browser/BrowserTabsAdapter.kt | 140 ++- .../tabstray/browser/BrowserTrayInteractor.kt | 210 ----- .../tabstray/browser/DraggableItemAnimator.kt | 2 +- .../tabstray/browser/FeatureNameHolder.kt | 14 + .../tabstray/browser/InactiveTabViewHolder.kt | 31 +- .../tabstray/browser/InactiveTabsAdapter.kt | 11 +- .../browser/InactiveTabsController.kt | 92 +- .../browser/InactiveTabsInteractor.kt | 82 +- .../tabstray/browser/NormalBrowserTrayList.kt | 5 +- .../tabstray/browser/NormalTabsBinding.kt | 2 +- .../browser/PrivateBrowserTrayList.kt | 5 +- .../tabstray/browser/PrivateTabsBinding.kt | 2 +- .../browser/SelectedItemAdapterBinding.kt | 2 +- .../browser/SelectionBannerBinding.kt | 32 +- .../browser/SelectionHandleBinding.kt | 12 +- .../android/tabstray/browser/SelectionMenu.kt | 10 +- .../browser/SelectionMenuIntegration.kt | 17 +- .../tabstray/browser/SwipeToDeleteBinding.kt | 4 +- .../android/tabstray/browser/TabSorter.kt | 2 +- .../android/tabstray/browser/TabsAdapter.kt | 2 +- .../tabstray/browser/TabsTouchHelper.kt | 35 +- .../tabstray/browser/TabsTrayFabController.kt | 25 + .../tabstray/browser/TabsTrayFabInteractor.kt | 27 + .../android/tabstray/browser/UseCases.kt | 33 - .../compose/ComposeAbstractTabViewHolder.kt | 2 +- .../browser/compose/ComposeGridViewHolder.kt | 29 +- .../browser/compose/ComposeListViewHolder.kt | 42 +- .../browser/compose/ReorderableGrid.kt | 302 +++++++ .../browser/compose/ReorderableList.kt | 285 ++++++ .../android/tabstray/ext/BrowserMenu.kt | 4 +- .../{WaterfoxSnackbar.kt => FenixSnackbar.kt} | 8 +- .../tabstray/ext/RecyclerViewAdapter.kt | 10 +- .../android/tabstray/ext/SyncedDeviceTabs.kt | 34 +- .../tabstray/ext/SyncedTabsViewErrorType.kt | 2 +- .../android/tabstray/ext/TabSelectors.kt | 2 +- .../android/tabstray/ext/TabsTrayState.kt | 172 ++++ .../tabstray/inactivetabs/InactiveTabs.kt | 23 +- .../tabstray/syncedtabs/SyncButtonBinding.kt | 4 +- .../android/tabstray/syncedtabs/SyncedTabs.kt | 122 ++- .../syncedtabs/SyncedTabsIntegration.kt | 11 +- .../tabstray/syncedtabs/SyncedTabsListItem.kt | 25 +- .../AbstractBrowserPageViewHolder.kt | 5 +- .../viewholders/AbstractPageViewHolder.kt | 4 +- .../NormalBrowserPageViewHolder.kt | 8 +- .../PrivateBrowserPageViewHolder.kt | 6 +- .../viewholders/SyncedTabsPageViewHolder.kt | 10 +- .../net/waterfox/android/utils/Settings.kt | 2 + .../tab_tray_grid_item_selected_border.xml | 27 + .../main/res/layout/component_tabstray2.xml | 1 + .../main/res/layout/component_tabstray3.xml | 12 + .../res/layout/component_tabstray3_fab.xml | 9 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/styles.xml | 7 + .../tabstray/CloseOnLastTabBindingTest.kt | 24 +- .../tabstray/DefaultTabsTrayControllerTest.kt | 827 +++++++++++++++++- .../tabstray/DefaultTabsTrayInteractorTest.kt | 142 ++- .../FloatingActionButtonBindingTest.kt | 42 +- .../tabstray/NavigationInteractorTest.kt | 117 +-- .../tabstray/SecureTabsTrayBindingTest.kt | 8 +- .../android/tabstray/TabLayoutObserverTest.kt | 3 - .../tabstray/TabSheetBehaviorManagerTest.kt | 583 +++++++++++- .../android/tabstray/TabsTrayDialogTest.kt | 4 +- .../android/tabstray/TabsTrayFragmentTest.kt | 75 +- .../tabstray/TabsTrayInfoBannerBindingTest.kt | 8 +- .../android/tabstray/TabsTrayStateTest.kt | 206 +++++ .../tabstray/TabsTrayStoreReducerTest.kt | 14 +- .../AbstractBrowserTabViewHolderTest.kt | 52 +- .../browser/BrowserTabsAdapterTest.kt | 61 +- .../DefaultInactiveTabsControllerTest.kt | 92 -- .../DefaultInactiveTabsInteractorTest.kt | 83 -- .../browser/RemoveTabUseCaseWrapperTest.kt | 40 - .../browser/SelectTabUseCaseWrapperTest.kt | 43 - .../browser/SelectionMenuIntegrationTest.kt | 29 +- .../android/tabstray/browser/TabSorterTest.kt | 24 +- .../tabstray/browser/TabsTouchHelperTest.kt | 17 +- .../tabstray/ext/BrowserStoreKtTest.kt | 12 +- .../android/tabstray/ext/ContextKtTest.kt | 4 +- .../tabstray/ext/TabSessionStateKtTest.kt | 24 +- .../tabstray/ext/WaterfoxSnackbarKtTest.kt | 7 +- .../AbstractBrowserPageViewHolderTest.kt | 10 +- 119 files changed, 7254 insertions(+), 2092 deletions(-) create mode 100644 app/src/main/java/net/waterfox/android/compose/Banner.kt create mode 100644 app/src/main/java/net/waterfox/android/compose/BottomSheetHandle.kt create mode 100644 app/src/main/java/net/waterfox/android/compose/SwipeToDismiss.kt create mode 100644 app/src/main/java/net/waterfox/android/compose/TabCounter.kt create mode 100644 app/src/main/java/net/waterfox/android/compose/annotation/LightDarkPreview.kt create mode 100644 app/src/main/java/net/waterfox/android/compose/button/FloatingActionButton.kt create mode 100644 app/src/main/java/net/waterfox/android/compose/ext/Int.kt create mode 100644 app/src/main/java/net/waterfox/android/compose/tabstray/DismissedTabBackground.kt create mode 100644 app/src/main/java/net/waterfox/android/tabstray/SyncedTabsController.kt create mode 100644 app/src/main/java/net/waterfox/android/tabstray/SyncedTabsInteractor.kt create mode 100644 app/src/main/java/net/waterfox/android/tabstray/TabsTray.kt create mode 100644 app/src/main/java/net/waterfox/android/tabstray/TabsTrayBanner.kt create mode 100644 app/src/main/java/net/waterfox/android/tabstray/TabsTrayFab.kt delete mode 100644 app/src/main/java/net/waterfox/android/tabstray/TabsTrayMiddleware.kt create mode 100644 app/src/main/java/net/waterfox/android/tabstray/TabsTrayTabLayouts.kt create mode 100644 app/src/main/java/net/waterfox/android/tabstray/TabsTrayTestTag.kt create mode 100644 app/src/main/java/net/waterfox/android/tabstray/browser/BrowserTabViewHolder.kt delete mode 100644 app/src/main/java/net/waterfox/android/tabstray/browser/BrowserTrayInteractor.kt create mode 100644 app/src/main/java/net/waterfox/android/tabstray/browser/FeatureNameHolder.kt create mode 100644 app/src/main/java/net/waterfox/android/tabstray/browser/TabsTrayFabController.kt create mode 100644 app/src/main/java/net/waterfox/android/tabstray/browser/TabsTrayFabInteractor.kt delete mode 100644 app/src/main/java/net/waterfox/android/tabstray/browser/UseCases.kt create mode 100644 app/src/main/java/net/waterfox/android/tabstray/browser/compose/ReorderableGrid.kt create mode 100644 app/src/main/java/net/waterfox/android/tabstray/browser/compose/ReorderableList.kt rename app/src/main/java/net/waterfox/android/tabstray/ext/{WaterfoxSnackbar.kt => FenixSnackbar.kt} (93%) create mode 100644 app/src/main/res/drawable/tab_tray_grid_item_selected_border.xml create mode 100644 app/src/main/res/layout/component_tabstray3.xml create mode 100644 app/src/main/res/layout/component_tabstray3_fab.xml create mode 100644 app/src/test/java/net/waterfox/android/tabstray/TabsTrayStateTest.kt delete mode 100644 app/src/test/java/net/waterfox/android/tabstray/browser/DefaultInactiveTabsControllerTest.kt delete mode 100644 app/src/test/java/net/waterfox/android/tabstray/browser/DefaultInactiveTabsInteractorTest.kt delete mode 100644 app/src/test/java/net/waterfox/android/tabstray/browser/RemoveTabUseCaseWrapperTest.kt delete mode 100644 app/src/test/java/net/waterfox/android/tabstray/browser/SelectTabUseCaseWrapperTest.kt diff --git a/app/src/main/java/net/waterfox/android/components/bookmarks/BookmarksUseCase.kt b/app/src/main/java/net/waterfox/android/components/bookmarks/BookmarksUseCase.kt index 2886e8c02..8ed0425f2 100644 --- a/app/src/main/java/net/waterfox/android/components/bookmarks/BookmarksUseCase.kt +++ b/app/src/main/java/net/waterfox/android/components/bookmarks/BookmarksUseCase.kt @@ -29,16 +29,21 @@ class BookmarksUseCase( * one with the identical [url] already exists. */ @WorkerThread - suspend operator fun invoke(url: String, title: String, position: UInt? = null): Boolean { + suspend operator fun invoke( + url: String, + title: String, + position: UInt? = null, + parentGuid: String? = null, + ): Boolean { return try { val canAdd = storage.getBookmarksWithUrl(url).firstOrNull { it.url == url } == null if (canAdd) { storage.addItem( - BookmarkRoot.Mobile.id, + parentGuid ?: BookmarkRoot.Mobile.id, url = url, title = title, - position = position + position = position, ) } canAdd @@ -57,7 +62,7 @@ class BookmarksUseCase( */ class RetrieveRecentBookmarksUseCase internal constructor( private val bookmarksStorage: BookmarksStorage, - private val historyStorage: HistoryStorage? = null + private val historyStorage: HistoryStorage? = null, ) { /** * Retrieves a list of recently added bookmarks, if any, up to maximum. @@ -70,14 +75,14 @@ class BookmarksUseCase( @WorkerThread suspend operator fun invoke( count: Int = DEFAULT_BOOKMARKS_TO_RETRIEVE, - maxAgeInMs: Long = TimeUnit.DAYS.toMillis(DEFAULT_BOOKMARKS_DAYS_AGE_TO_RETRIEVE) + maxAgeInMs: Long = TimeUnit.DAYS.toMillis(DEFAULT_BOOKMARKS_DAYS_AGE_TO_RETRIEVE), ): List { val currentTime = System.currentTimeMillis() // Fetch visit information within the time range of now and the specified maximum age. val history = historyStorage?.getDetailedVisits( start = currentTime - maxAgeInMs, - end = currentTime + end = currentTime, ) return bookmarksStorage @@ -86,7 +91,7 @@ class BookmarksUseCase( RecentBookmark( title = bookmark.title, url = bookmark.url, - previewImageUrl = history?.find { bookmark.url == it.url }?.previewImageUrl + previewImageUrl = history?.find { bookmark.url == it.url }?.previewImageUrl, ) } } @@ -96,14 +101,16 @@ class BookmarksUseCase( val retrieveRecentBookmarks by lazy { RetrieveRecentBookmarksUseCase( bookmarksStorage, - historyStorage + historyStorage, ) } companion object { // Number of recent bookmarks to retrieve. const val DEFAULT_BOOKMARKS_TO_RETRIEVE = 4 + // The maximum age in days of a recent bookmarks to retrieve. const val DEFAULT_BOOKMARKS_DAYS_AGE_TO_RETRIEVE = 10L } } + diff --git a/app/src/main/java/net/waterfox/android/compose/Banner.kt b/app/src/main/java/net/waterfox/android/compose/Banner.kt new file mode 100644 index 000000000..62380d353 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/compose/Banner.kt @@ -0,0 +1,83 @@ +/* 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 net.waterfox.android.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +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.width +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import net.waterfox.android.compose.annotation.LightDarkPreview +import net.waterfox.android.compose.button.TextButton +import net.waterfox.android.theme.WaterfoxTheme + +/** + * Default layout for a Banner messaging surface with two text buttons. + * + * @param message The primary text displayed to the user. + * @param button1Text The text of the first button. + * @param button2Text The text of the second button. + * @param onButton1Click Invoked when the first button is clicked. + * @param onButton2Click Invoked when the second button is clicked. + */ +@Composable +fun Banner( + message: String, + button1Text: String, + button2Text: String, + onButton1Click: () -> Unit, + onButton2Click: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(WaterfoxTheme.colors.layer1) + .padding(all = 16.dp), + ) { + Text( + text = message, + color = WaterfoxTheme.colors.textPrimary, + style = WaterfoxTheme.typography.body2, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row(modifier = Modifier.align(Alignment.End)) { + TextButton( + text = button1Text, + onClick = onButton2Click, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + TextButton( + text = button2Text, + onClick = onButton1Click, + ) + } + } +} + +@LightDarkPreview +@Composable +private fun BannerPreview() { + WaterfoxTheme { + Banner( + message = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer sodales laoreet commodo.", + button1Text = "Button 1", + button2Text = "Button 2", + onButton1Click = {}, + onButton2Click = {}, + ) + } +} diff --git a/app/src/main/java/net/waterfox/android/compose/BottomSheetHandle.kt b/app/src/main/java/net/waterfox/android/compose/BottomSheetHandle.kt new file mode 100644 index 000000000..1a5b2867c --- /dev/null +++ b/app/src/main/java/net/waterfox/android/compose/BottomSheetHandle.kt @@ -0,0 +1,74 @@ +/* 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 net.waterfox.android.compose + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.res.dimensionResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import net.waterfox.android.R +import net.waterfox.android.compose.annotation.LightDarkPreview +import net.waterfox.android.theme.WaterfoxTheme + +/** + * A handle present on top of a bottom sheet. This is selectable when talkback is enabled. + * + * @param onRequestDismiss Invoked on clicking the handle when talkback is enabled. + * @param contentDescription Content Description of the composable. + * @param modifier The modifier to be applied to the Composable. + * @param color Color of the handle. + */ +@Composable +fun BottomSheetHandle( + onRequestDismiss: () -> Unit, + contentDescription: String, + modifier: Modifier = Modifier, + color: Color = WaterfoxTheme.colors.textSecondary, +) { + Canvas( + modifier = modifier + .height(dimensionResource(id = R.dimen.bottom_sheet_handle_height)) + .semantics(mergeDescendants = true) { + this.contentDescription = contentDescription + onClick { + onRequestDismiss() + true + } + }, + ) { + drawRect(color = color) + } +} + +@Composable +@LightDarkPreview +private fun BottomSheetHandlePreview() { + WaterfoxTheme { + Column( + modifier = Modifier + .background(color = WaterfoxTheme.colors.layer1) + .padding(16.dp), + ) { + BottomSheetHandle( + onRequestDismiss = {}, + contentDescription = "", + modifier = Modifier + .width(100.dp) + .align(Alignment.CenterHorizontally), + ) + } + } +} diff --git a/app/src/main/java/net/waterfox/android/compose/SwipeToDismiss.kt b/app/src/main/java/net/waterfox/android/compose/SwipeToDismiss.kt new file mode 100644 index 000000000..42e5996ed --- /dev/null +++ b/app/src/main/java/net/waterfox/android/compose/SwipeToDismiss.kt @@ -0,0 +1,169 @@ +/* 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 net.waterfox.android.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.offset +import androidx.compose.material.DismissDirection +import androidx.compose.material.DismissDirection.EndToStart +import androidx.compose.material.DismissDirection.StartToEnd +import androidx.compose.material.DismissState +import androidx.compose.material.DismissValue +import androidx.compose.material.DismissValue.Default +import androidx.compose.material.DismissValue.DismissedToEnd +import androidx.compose.material.DismissValue.DismissedToStart +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.FixedThreshold +import androidx.compose.material.FractionalThreshold +import androidx.compose.material.Text +import androidx.compose.material.ThresholdConfig +import androidx.compose.material.rememberDismissState +import androidx.compose.material.swipeable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import net.waterfox.android.theme.WaterfoxTheme +import kotlin.math.roundToInt + +/** + * A composable that can be dismissed by swiping left or right + * + * @param state The state of this component. + * @param modifier Optional [Modifier] for this component. + * @param enabled [Boolean] controlling whether the content is swipeable or not. + * @param directions The set of directions in which the component can be dismissed. + * @param dismissThreshold The threshold the item needs to be swiped in order to be dismissed. + * @param backgroundContent A composable that is stacked behind the primary content and is exposed + * when the content is swiped. You can/should use the [state] to have different backgrounds on each side. + * @param dismissContent The content that can be dismissed. + */ +@Composable +@ExperimentalMaterialApi +fun SwipeToDismiss( + state: DismissState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + directions: Set = setOf(EndToStart, StartToEnd), + dismissThreshold: ThresholdConfig = FractionalThreshold(DISMISS_THRESHOLD), + backgroundContent: @Composable RowScope.() -> Unit, + dismissContent: @Composable RowScope.() -> Unit, +) { + val swipeWidth = with(LocalDensity.current) { + LocalConfiguration.current.screenWidthDp.dp.toPx() + } + val anchors = mutableMapOf(0f to Default) + val thresholds = { _: DismissValue, _: DismissValue -> + dismissThreshold + } + + if (StartToEnd in directions) anchors += swipeWidth to DismissedToEnd + if (EndToStart in directions) anchors += -swipeWidth to DismissedToStart + + Box( + Modifier + .swipeable( + state = state, + anchors = anchors, + thresholds = thresholds, + orientation = Orientation.Horizontal, + enabled = state.currentValue == Default && enabled, + reverseDirection = LocalLayoutDirection.current == LayoutDirection.Rtl, + resistance = null, + ) + .then(modifier), + ) { + Row( + content = backgroundContent, + modifier = Modifier.matchParentSize(), + ) + + Row( + content = dismissContent, + modifier = Modifier.offset { IntOffset(state.offset.value.roundToInt(), 0) }, + ) + } +} + +private const val DISMISS_THRESHOLD = 0.5f + +@OptIn(ExperimentalMaterialApi::class) +@Composable +private fun SwipeablePreview(directions: Set, text: String, threshold: ThresholdConfig) { + val state = rememberDismissState() + + Box( + modifier = Modifier + .height(30.dp) + .fillMaxWidth(), + ) { + SwipeToDismiss( + state = state, + directions = directions, + dismissThreshold = threshold, + backgroundContent = { + Box( + modifier = Modifier + .fillMaxSize() + .background(WaterfoxTheme.colors.layerAccent), + ) + }, + ) { + Row( + modifier = Modifier + .fillMaxSize() + .background(WaterfoxTheme.colors.layer1), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text) + } + } + } +} + +@Suppress("MagicNumber") +@OptIn(ExperimentalMaterialApi::class) +@Composable +@Preview +private fun SwipeToDismissPreview() { + WaterfoxTheme { + Column { + SwipeablePreview( + directions = setOf(StartToEnd), + text = "Swipe to right 50% ->", + FractionalThreshold(.5f), + ) + Spacer(Modifier.height(30.dp)) + SwipeablePreview( + directions = setOf(EndToStart), + text = "<- Swipe to left 100%", + FractionalThreshold(1f), + ) + Spacer(Modifier.height(30.dp)) + SwipeablePreview( + directions = setOf(StartToEnd, EndToStart), + text = "<- Swipe both ways 20dp ->", + FixedThreshold(20.dp), + ) + } + } +} diff --git a/app/src/main/java/net/waterfox/android/compose/TabCounter.kt b/app/src/main/java/net/waterfox/android/compose/TabCounter.kt new file mode 100644 index 000000000..927c6e137 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/compose/TabCounter.kt @@ -0,0 +1,136 @@ +/* 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 net.waterfox.android.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.waterfox.android.R +import net.waterfox.android.compose.annotation.LightDarkPreview +import net.waterfox.android.compose.ext.toLocaleString +import net.waterfox.android.tabstray.TabsTrayTestTag +import net.waterfox.android.theme.WaterfoxTheme + +private const val MAX_VISIBLE_TABS = 99 +private const val SO_MANY_TABS_OPEN = "∞" +private val NORMAL_TABS_BOTTOM_PADDING = 0.5.dp +private const val ONE_DIGIT_SIZE_RATIO = 0.5f +private const val TWO_DIGITS_SIZE_RATIO = 0.4f +private const val MIN_SINGLE_DIGIT = 0 +private const val MAX_SINGLE_DIGIT = 9 +private const val TWO_DIGIT_THRESHOLD = 10 +private const val TAB_TEXT_BOTTOM_PADDING_RATIO = 4 + +/** + * UI for displaying the number of opened tabs. +* +* This composable uses LocalContentColor, provided by CompositionLocalProvider, +* to set the color of its icons and text. +* +* @param tabCount the number to be displayed inside the counter. +*/ + +@Composable +fun TabCounter(tabCount: Int) { + val formattedTabCount = tabCount.toLocaleString() + val normalTabCountText: String + val tabCountTextRatio: Float + val needsBottomPaddingForInfiniteTabs: Boolean + + when (tabCount) { + in MIN_SINGLE_DIGIT..MAX_SINGLE_DIGIT -> { + normalTabCountText = formattedTabCount + tabCountTextRatio = ONE_DIGIT_SIZE_RATIO + needsBottomPaddingForInfiniteTabs = false + } + + in TWO_DIGIT_THRESHOLD..MAX_VISIBLE_TABS -> { + normalTabCountText = formattedTabCount + tabCountTextRatio = TWO_DIGITS_SIZE_RATIO + needsBottomPaddingForInfiniteTabs = false + } + + else -> { + normalTabCountText = SO_MANY_TABS_OPEN + tabCountTextRatio = ONE_DIGIT_SIZE_RATIO + needsBottomPaddingForInfiniteTabs = true + } + } + + val normalTabsContentDescription = if (tabCount == 1) { + stringResource(id = R.string.mozac_tab_counter_open_tab_tray_single) + } else { + stringResource( + id = R.string.mozac_tab_counter_open_tab_tray_plural, + formattedTabCount, + ) + } + + val counterBoxWidthDp = + dimensionResource(id = mozilla.components.ui.tabcounter.R.dimen.mozac_tab_counter_box_width_height) + val counterBoxWidthPx = LocalDensity.current.run { counterBoxWidthDp.roundToPx() } + val counterTabsTextSize = (tabCountTextRatio * counterBoxWidthPx).toInt() + + val normalTabsTextModifier = if (needsBottomPaddingForInfiniteTabs) { + val bottomPadding = with(LocalDensity.current) { counterTabsTextSize.toDp() / TAB_TEXT_BOTTOM_PADDING_RATIO } + Modifier.padding(bottom = bottomPadding) + } else { + Modifier.padding(bottom = NORMAL_TABS_BOTTOM_PADDING) + } + + Box( + modifier = Modifier + .semantics(mergeDescendants = true) { + testTag = TabsTrayTestTag.normalTabsCounter + }, + contentAlignment = Alignment.Center, + ) { + Icon( + painter = painterResource( + id = mozilla.components.ui.tabcounter.R.drawable.mozac_ui_tabcounter_box, + ), + contentDescription = normalTabsContentDescription, + ) + + Text( + text = normalTabCountText, + modifier = normalTabsTextModifier, + color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current), + fontSize = with(LocalDensity.current) { counterTabsTextSize.toDp().toSp() }, + fontWeight = FontWeight.W700, + textAlign = TextAlign.Center, + ) + } +} + +@LightDarkPreview +@Preview(locale = "ar") +@Composable +private fun TabCounterPreview() { + WaterfoxTheme { + Box( + modifier = Modifier.background(color = WaterfoxTheme.colors.layer1), + ) { + TabCounter(tabCount = 55) + } + } +} diff --git a/app/src/main/java/net/waterfox/android/compose/annotation/LightDarkPreview.kt b/app/src/main/java/net/waterfox/android/compose/annotation/LightDarkPreview.kt new file mode 100644 index 000000000..ddb568219 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/compose/annotation/LightDarkPreview.kt @@ -0,0 +1,16 @@ +/* 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 net.waterfox.android.compose.annotation + +import android.content.res.Configuration +import androidx.compose.ui.tooling.preview.Preview + +/** + * A wrapper annotation for the two uiMode that are commonly used + * in Compose preview functions. + */ +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +annotation class LightDarkPreview diff --git a/app/src/main/java/net/waterfox/android/compose/button/FloatingActionButton.kt b/app/src/main/java/net/waterfox/android/compose/button/FloatingActionButton.kt new file mode 100644 index 000000000..082716139 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/compose/button/FloatingActionButton.kt @@ -0,0 +1,105 @@ +/* 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 net.waterfox.android.compose.button + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material.FloatingActionButton +import androidx.compose.material.FloatingActionButtonDefaults +import androidx.compose.material.FloatingActionButtonElevation +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +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.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import net.waterfox.android.R +import net.waterfox.android.compose.annotation.LightDarkPreview +import net.waterfox.android.theme.WaterfoxTheme + +/** + * Floating action button. + * + * @param icon [Painter] icon to be displayed inside the action button. + * @param modifier [Modifier] to be applied to the action button. + * @param contentDescription The content description to describe the icon. + * @param label Text to be displayed next to the icon. + * @param elevation [FloatingActionButtonElevation] used to resolve the elevation for this FAB in different states. + * This controls the size of the shadow below the FAB. + * @param onClick Invoked when the button is clicked. + */ +@Composable +fun FloatingActionButton( + icon: Painter, + modifier: Modifier = Modifier, + contentDescription: String? = null, + label: String? = null, + elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 5.dp, + pressedElevation = 5.dp, + ), + onClick: () -> Unit, +) { + FloatingActionButton( + onClick = onClick, + modifier = modifier, + backgroundColor = WaterfoxTheme.colors.actionPrimary, + contentColor = WaterfoxTheme.colors.textActionPrimary, + elevation = elevation, + ) { + Row( + modifier = Modifier + .wrapContentSize() + .padding(16.dp) + .animateContentSize(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = icon, + contentDescription = contentDescription, + tint = WaterfoxTheme.colors.iconOnColor, + ) + + if (!label.isNullOrBlank()) { + Spacer(Modifier.width(12.dp)) + + Text( + text = label, + style = WaterfoxTheme.typography.button, + maxLines = 1, + ) + } + } + } +} + +@LightDarkPreview +@Composable +private fun FloatingActionButtonPreview() { + var label by remember { mutableStateOf("LABEL") } + + WaterfoxTheme { + Box(Modifier.wrapContentSize()) { + FloatingActionButton( + label = label, + icon = painterResource(R.drawable.ic_new), + onClick = { + label = if (label == null) "LABEL" else null + }, + ) + } + } +} diff --git a/app/src/main/java/net/waterfox/android/compose/ext/Int.kt b/app/src/main/java/net/waterfox/android/compose/ext/Int.kt new file mode 100644 index 000000000..108532212 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/compose/ext/Int.kt @@ -0,0 +1,15 @@ +/* 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 net.waterfox.android.compose.ext + +import androidx.compose.ui.text.intl.Locale +import java.text.NumberFormat +import java.util.Locale as JavaLocale + +/** + * Returns a localized string representation of the value. + */ +fun Int.toLocaleString(): String = + NumberFormat.getNumberInstance(JavaLocale(Locale.current.language)).format(this) diff --git a/app/src/main/java/net/waterfox/android/compose/tabstray/DismissedTabBackground.kt b/app/src/main/java/net/waterfox/android/compose/tabstray/DismissedTabBackground.kt new file mode 100644 index 000000000..8fb4cb6b7 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/compose/tabstray/DismissedTabBackground.kt @@ -0,0 +1,112 @@ +/* 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 net.waterfox.android.compose.tabstray + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.DismissDirection +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import mozilla.components.feature.tab.collections.Tab +import net.waterfox.android.R +import net.waterfox.android.compose.annotation.LightDarkPreview +import net.waterfox.android.theme.WaterfoxTheme + +/** + * The background of a [Tab] that is being swiped left or right. + * + * @param dismissDirection [DismissDirection] of the ongoing swipe. Depending on the direction, + * the background will also include a warning icon at the start of the swipe gesture. + * If `null` the warning icon will be shown at both ends. + * @param shape Shape of the background. + */ +@Composable +fun DismissedTabBackground( + dismissDirection: DismissDirection?, + shape: Shape, +) { + Card( + modifier = Modifier.fillMaxSize(), + backgroundColor = WaterfoxTheme.colors.layer3, + shape = shape, + elevation = 0.dp, + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterResource(R.drawable.ic_delete), + contentDescription = null, + modifier = Modifier + .padding(horizontal = 32.dp) + // Only show the delete icon for where the swipe starts. + .alpha( + if (dismissDirection == DismissDirection.StartToEnd || dismissDirection == null) 1f else 0f, + ), + tint = WaterfoxTheme.colors.iconWarning, + ) + + Icon( + painter = painterResource(R.drawable.ic_delete), + contentDescription = null, + modifier = Modifier + .padding(horizontal = 32.dp) + // Only show the delete icon for where the swipe starts. + .alpha( + if (dismissDirection == DismissDirection.EndToStart || dismissDirection == null) 1f else 0f, + ), + tint = WaterfoxTheme.colors.iconWarning, + ) + } + } +} + +@Composable +@LightDarkPreview +private fun DismissedTabBackgroundPreview() { + WaterfoxTheme { + Column { + Box(modifier = Modifier.height(56.dp)) { + DismissedTabBackground( + dismissDirection = DismissDirection.StartToEnd, + shape = RoundedCornerShape(0.dp), + ) + } + + Spacer(Modifier.height(10.dp)) + + Box(modifier = Modifier.height(56.dp)) { + DismissedTabBackground( + dismissDirection = DismissDirection.EndToStart, + shape = RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp), + ) + } + + Spacer(Modifier.height(10.dp)) + + Box(modifier = Modifier.height(56.dp)) { + DismissedTabBackground( + dismissDirection = null, + shape = RoundedCornerShape(0.dp), + ) + } + } + } +} diff --git a/app/src/main/java/net/waterfox/android/compose/tabstray/MediaImage.kt b/app/src/main/java/net/waterfox/android/compose/tabstray/MediaImage.kt index 673895a27..ee591a4e9 100644 --- a/app/src/main/java/net/waterfox/android/compose/tabstray/MediaImage.kt +++ b/app/src/main/java/net/waterfox/android/compose/tabstray/MediaImage.kt @@ -4,25 +4,24 @@ package net.waterfox.android.compose.tabstray -import android.content.res.Configuration import androidx.appcompat.content.res.AppCompatResources import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource 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.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.accompanist.drawablepainter.rememberDrawablePainter import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.createTab import mozilla.components.concept.engine.mediasession.MediaSession.PlaybackState import net.waterfox.android.R +import net.waterfox.android.compose.annotation.LightDarkPreview import net.waterfox.android.theme.WaterfoxTheme -import net.waterfox.android.theme.Theme /** * Controller buttons for the media (play/pause) state for the given [tab]. @@ -30,12 +29,14 @@ import net.waterfox.android.theme.Theme * @param tab [TabSessionState] which the image should be shown. * @param onMediaIconClicked handles the click event when tab has media session like play/pause. * @param modifier [Modifier] to be applied to the layout. + * @param interactionSource [MutableInteractionSource] used to propagate the ripple effect on click. */ @Composable fun MediaImage( tab: TabSessionState, onMediaIconClicked: ((TabSessionState) -> Unit), modifier: Modifier, + interactionSource: MutableInteractionSource = MutableInteractionSource(), ) { val (icon, contentDescription) = when (tab.mediaSessionState?.playbackState) { PlaybackState.PAUSED -> { @@ -51,21 +52,23 @@ fun MediaImage( Image( painter = rememberDrawablePainter(drawable = drawable), contentDescription = stringResource(contentDescription), - modifier = modifier.clickable { onMediaIconClicked(tab) }, + modifier = modifier.clickable( + interactionSource = interactionSource, + indication = null, + ) { onMediaIconClicked(tab) }, ) } @Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@LightDarkPreview private fun ImagePreview() { - WaterfoxTheme(theme = Theme.getTheme()) { + WaterfoxTheme { MediaImage( tab = createTab(url = "https://mozilla.com"), onMediaIconClicked = {}, modifier = Modifier .height(100.dp) - .width(200.dp) + .width(200.dp), ) } } diff --git a/app/src/main/java/net/waterfox/android/compose/tabstray/TabGridItem.kt b/app/src/main/java/net/waterfox/android/compose/tabstray/TabGridItem.kt index 019842efa..a7fdc5946 100644 --- a/app/src/main/java/net/waterfox/android/compose/tabstray/TabGridItem.kt +++ b/app/src/main/java/net/waterfox/android/compose/tabstray/TabGridItem.kt @@ -4,7 +4,6 @@ package net.waterfox.android.compose.tabstray -import android.content.res.Configuration import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image @@ -12,6 +11,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -24,35 +25,50 @@ import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card +import androidx.compose.material.DismissValue +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.Text +import androidx.compose.material.rememberDismissState +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.zIndex import androidx.core.text.BidiFormatter import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.createTab import mozilla.components.browser.thumbnails.storage.ThumbnailStorage +import mozilla.components.support.ktx.kotlin.MAX_URI_LENGTH +import mozilla.components.ui.colors.PhotonColors import net.waterfox.android.R import net.waterfox.android.compose.Divider import net.waterfox.android.compose.HorizontalFadingEdgeBox +import net.waterfox.android.compose.SwipeToDismiss import net.waterfox.android.compose.TabThumbnail +import net.waterfox.android.compose.annotation.LightDarkPreview +import net.waterfox.android.tabstray.TabsTrayTestTag +import net.waterfox.android.tabstray.ext.toDisplayTitle import net.waterfox.android.theme.WaterfoxTheme /** @@ -60,19 +76,22 @@ import net.waterfox.android.theme.WaterfoxTheme * long clicks, multiple selection, and media controls. * * @param tab The given tab to be render as view a grid item. + * @param storage [ThumbnailStorage] to obtain tab thumbnail bitmaps from. + * @param thumbnailSize Size of tab's thumbnail. * @param isSelected Indicates if the item should be render as selected. * @param multiSelectionEnabled Indicates if the item should be render with multi selection options, * enabled. * @param multiSelectionSelected Indicates if the item should be render as multi selection selected * option. + * @param shouldClickListen Whether or not the item should stop listening to click events. * @param onCloseClick Callback to handle the click event of the close button. * @param onMediaClick Callback to handle when the media item is clicked. * @param onClick Callback to handle when item is clicked. - * @param onLongClick Callback to handle when item is long clicked. + * @param onLongClick Optional callback to handle when item is long clicked. */ -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) @Composable -@Suppress("MagicNumber", "LongParameterList", "LongMethod") +@Suppress("MagicNumber", "LongMethod") fun TabGridItem( tab: TabSessionState, storage: ThumbnailStorage, @@ -80,122 +99,181 @@ fun TabGridItem( isSelected: Boolean = false, multiSelectionEnabled: Boolean = false, multiSelectionSelected: Boolean = false, + shouldClickListen: Boolean = true, onCloseClick: (tab: TabSessionState) -> Unit, onMediaClick: (tab: TabSessionState) -> Unit, onClick: (tab: TabSessionState) -> Unit, - onLongClick: (tab: TabSessionState) -> Unit, + onLongClick: ((tab: TabSessionState) -> Unit)? = null, ) { - val tabBorderModifier = if (isSelected && !multiSelectionEnabled) { + val tabBorderModifier = if (isSelected) { Modifier.border( 4.dp, WaterfoxTheme.colors.borderAccent, - RoundedCornerShape(12.dp) + RoundedCornerShape(12.dp), ) } else { Modifier } - Box( - modifier = Modifier - .wrapContentHeight() - .wrapContentWidth() + val dismissState = rememberDismissState( + confirmStateChange = { dismissValue -> + if (dismissValue == DismissValue.DismissedToEnd || dismissValue == DismissValue.DismissedToStart) { + onCloseClick(tab) + } + false + }, + ) + + // Used to propagate the ripple effect to the whole tab + val interactionSource = remember { MutableInteractionSource() } + + SwipeToDismiss( + state = dismissState, + enabled = !multiSelectionEnabled, + backgroundContent = {}, + modifier = Modifier.zIndex( + if (dismissState.dismissDirection == null) { + 0f + } else { + 1f + }, + ), ) { - Card( + Box( modifier = Modifier - .fillMaxWidth() - .height(202.dp) - .padding(4.dp) - .then(tabBorderModifier) - .padding(4.dp) - .combinedClickable( - onLongClick = { onLongClick(tab) }, - onClick = { onClick(tab) } - ), - elevation = 0.dp, - shape = RoundedCornerShape(dimensionResource(id = R.dimen.tab_tray_grid_item_border_radius)), - border = BorderStroke(1.dp, WaterfoxTheme.colors.borderPrimary) + .wrapContentSize() + .testTag(TabsTrayTestTag.tabItemRoot), ) { - Column( - modifier = Modifier.background(WaterfoxTheme.colors.layer2) + val clickableModifier = if (onLongClick == null) { + Modifier.clickable( + enabled = shouldClickListen, + interactionSource = interactionSource, + indication = rememberRipple( + color = clickableColor(), + ), + onClick = { onClick(tab) }, + ) + } else { + Modifier.combinedClickable( + enabled = shouldClickListen, + interactionSource = interactionSource, + indication = rememberRipple( + color = clickableColor(), + ), + onLongClick = { onLongClick(tab) }, + onClick = { onClick(tab) }, + ) + } + Card( + modifier = Modifier + .fillMaxWidth() + .height(202.dp) + .padding(4.dp) + .then(tabBorderModifier) + .padding(4.dp) + .then(clickableModifier), + elevation = 0.dp, + shape = RoundedCornerShape(dimensionResource(id = R.dimen.tab_tray_grid_item_border_radius)), + border = BorderStroke(1.dp, WaterfoxTheme.colors.borderPrimary), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() + Column( + modifier = Modifier.background(WaterfoxTheme.colors.layer2), ) { - Spacer(modifier = Modifier.width(8.dp)) - - tab.content.icon?.let { icon -> - icon.prepareToDraw() - Image( - bitmap = icon.asImageBitmap(), - contentDescription = null, - modifier = Modifier - .align(Alignment.CenterVertically) - .size(16.dp), - ) - } - - HorizontalFadingEdgeBox( + Row( modifier = Modifier - .weight(1f) - .wrapContentHeight() - .requiredHeight(30.dp) - .padding(7.dp, 5.dp) - .clipToBounds(), - backgroundColor = WaterfoxTheme.colors.layer2, - isContentRtl = BidiFormatter.getInstance().isRtl(tab.content.title) + .fillMaxWidth() + .wrapContentHeight(), ) { - Text( - text = tab.content.title, - fontSize = 14.sp, - maxLines = 1, - softWrap = false, - style = TextStyle( - color = WaterfoxTheme.colors.textPrimary, - textDirection = TextDirection.Content + Spacer(modifier = Modifier.width(8.dp)) + + tab.content.icon?.let { icon -> + icon.prepareToDraw() + Image( + bitmap = icon.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .align(Alignment.CenterVertically) + .size(16.dp), ) - ) + } + + HorizontalFadingEdgeBox( + modifier = Modifier + .weight(1f) + .wrapContentHeight() + .requiredHeight(30.dp) + .padding(7.dp, 5.dp) + .clipToBounds(), + backgroundColor = WaterfoxTheme.colors.layer2, + isContentRtl = BidiFormatter.getInstance().isRtl(tab.content.title), + ) { + Text( + text = tab.toDisplayTitle().take(MAX_URI_LENGTH), + fontSize = 14.sp, + maxLines = 1, + softWrap = false, + style = TextStyle( + color = WaterfoxTheme.colors.textPrimary, + textDirection = TextDirection.Content, + ), + ) + } + + if (!multiSelectionEnabled) { + IconButton( + modifier = Modifier + .size(24.dp) + .align(Alignment.CenterVertically) + .testTag(TabsTrayTestTag.tabItemClose), + onClick = { + onCloseClick(tab) + }, + ) { + Icon( + painter = painterResource(id = R.drawable.mozac_ic_cross_20), + contentDescription = stringResource(id = R.string.close_tab), + tint = WaterfoxTheme.colors.iconPrimary, + ) + } + } } - Icon( - painter = painterResource(id = R.drawable.mozac_ic_cross_20), - contentDescription = stringResource(id = R.string.close_tab), - tint = WaterfoxTheme.colors.iconPrimary, - modifier = Modifier - .clickable { onCloseClick(tab) } - .size(24.dp) - .align(Alignment.CenterVertically) + Divider() + Thumbnail( + tab = tab, + size = thumbnailSize, + storage = storage, + multiSelectionSelected = multiSelectionSelected, ) } + } - Divider() - - Thumbnail( + if (!multiSelectionEnabled) { + MediaImage( tab = tab, - size = thumbnailSize, - storage = storage, - multiSelectionSelected = multiSelectionSelected, + onMediaIconClicked = { onMediaClick(tab) }, + modifier = Modifier + .align(Alignment.TopStart), + interactionSource = interactionSource, ) } } - - if (!multiSelectionEnabled) { - MediaImage( - tab = tab, - onMediaIconClicked = { onMediaClick(tab) }, - modifier = Modifier - .align(Alignment.TopStart) - ) - } } } +@Composable +private fun clickableColor() = when (isSystemInDarkTheme()) { + true -> PhotonColors.White + false -> PhotonColors.Black +} + /** * Thumbnail specific for the [TabGridItem], which can be selected. * * @param tab Tab, containing the thumbnail to be displayed. + * @param size Size of the thumbnail. + * @param storage [ThumbnailStorage] to obtain tab thumbnail bitmaps from. * @param multiSelectionSelected Whether or not the multiple selection is enabled. */ @Composable @@ -209,20 +287,22 @@ private fun Thumbnail( modifier = Modifier .fillMaxSize() .background(WaterfoxTheme.colors.layer2) + .semantics(mergeDescendants = true) { + testTag = TabsTrayTestTag.tabItemThumbnail + }, ) { TabThumbnail( tab = tab, size = size, storage = storage, - backgroundColor = WaterfoxTheme.colors.layer2, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), ) if (multiSelectionSelected) { Box( modifier = Modifier .fillMaxSize() - .background(WaterfoxTheme.colors.layerAccentNonOpaque) + .background(WaterfoxTheme.colors.layerAccentNonOpaque), ) Card( @@ -238,7 +318,7 @@ private fun Thumbnail( .matchParentSize() .padding(all = 8.dp), contentDescription = null, - tint = colorResource(id = R.color.mozac_ui_icons_fill) + tint = colorResource(id = R.color.mozac_ui_icons_fill), ) } } @@ -246,28 +326,25 @@ private fun Thumbnail( } @Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@LightDarkPreview private fun TabGridItemPreview() { WaterfoxTheme { TabGridItem( tab = createTab( url = "www.mozilla.com", - title = "Mozilla Domain" + title = "Mozilla Domain", ), thumbnailSize = 108, storage = ThumbnailStorage(LocalContext.current), onCloseClick = {}, onMediaClick = {}, onClick = {}, - onLongClick = {}, ) } } @Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@LightDarkPreview private fun TabGridItemSelectedPreview() { WaterfoxTheme { TabGridItem( @@ -284,8 +361,7 @@ private fun TabGridItemSelectedPreview() { } @Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@LightDarkPreview private fun TabGridItemMultiSelectedPreview() { WaterfoxTheme { TabGridItem( diff --git a/app/src/main/java/net/waterfox/android/compose/tabstray/TabListItem.kt b/app/src/main/java/net/waterfox/android/compose/tabstray/TabListItem.kt index 29d3b0680..e5af977ca 100644 --- a/app/src/main/java/net/waterfox/android/compose/tabstray/TabListItem.kt +++ b/app/src/main/java/net/waterfox/android/compose/tabstray/TabListItem.kt @@ -4,58 +4,78 @@ package net.waterfox.android.compose.tabstray -import android.content.res.Configuration import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Card +import androidx.compose.material.DismissValue +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.Text +import androidx.compose.material.rememberDismissState +import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.createTab import mozilla.components.browser.thumbnails.storage.ThumbnailStorage +import mozilla.components.support.ktx.kotlin.MAX_URI_LENGTH +import mozilla.components.ui.colors.PhotonColors import net.waterfox.android.R +import net.waterfox.android.compose.SwipeToDismiss import net.waterfox.android.compose.TabThumbnail +import net.waterfox.android.compose.annotation.LightDarkPreview import net.waterfox.android.ext.toShortUrl -import net.waterfox.android.theme.Theme +import net.waterfox.android.tabstray.TabsTrayTestTag +import net.waterfox.android.tabstray.ext.toDisplayTitle import net.waterfox.android.theme.WaterfoxTheme /** * List item used to display a tab that supports clicks, * long clicks, multiselection, and media controls. * - * @param tab The given tab to be render as view a list item. + * @param tab The given tab to be render as view a grid item. + * @param storage [ThumbnailStorage] to obtain tab thumbnail bitmaps from. + * @param thumbnailSize Size of tab's thumbnail. * @param isSelected Indicates if the item should be render as selected. * @param multiSelectionEnabled Indicates if the item should be render with multi selection options, * enabled. * @param multiSelectionSelected Indicates if the item should be render as multi selection selected * option. + * @param shouldClickListen Whether or not the item should stop listening to click events. * @param onCloseClick Callback to handle the click event of the close button. * @param onMediaClick Callback to handle when the media item is clicked. * @param onClick Callback to handle when item is clicked. - * @param onLongClick Callback to handle when item is long clicked. + * @param onLongClick Optional callback to handle when item is long clicked. */ -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) @Composable -@Suppress("MagicNumber") +@Suppress("MagicNumber", "LongMethod") fun TabListItem( tab: TabSessionState, storage: ThumbnailStorage, @@ -63,74 +83,131 @@ fun TabListItem( isSelected: Boolean = false, multiSelectionEnabled: Boolean = false, multiSelectionSelected: Boolean = false, + shouldClickListen: Boolean = true, onCloseClick: (tab: TabSessionState) -> Unit, onMediaClick: (tab: TabSessionState) -> Unit, onClick: (tab: TabSessionState) -> Unit, - onLongClick: (tab: TabSessionState) -> Unit, + onLongClick: ((tab: TabSessionState) -> Unit)? = null, ) { - val contentBackgroundColor = if (isSelected) { WaterfoxTheme.colors.layerAccentNonOpaque } else { WaterfoxTheme.colors.layer1 } - Row( - modifier = Modifier - .fillMaxWidth() - .background(contentBackgroundColor) - .combinedClickable( - onLongClick = { onLongClick(tab) }, - onClick = { onClick(tab) } - ) - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Thumbnail( - tab = tab, - size = thumbnailSize, - storage = storage, - multiSelectionEnabled = multiSelectionEnabled, - isSelected = multiSelectionSelected, - onMediaIconClicked = { onMediaClick(it) } + + val dismissState = rememberDismissState( + confirmStateChange = { dismissValue -> + if (dismissValue == DismissValue.DismissedToEnd || dismissValue == DismissValue.DismissedToStart) { + onCloseClick(tab) + true + } else { + false + } + }, + ) + + // Used to propagate the ripple effect to the whole tab + val interactionSource = remember { MutableInteractionSource() } + + val clickableModifier = if (onLongClick == null) { + Modifier.clickable( + enabled = shouldClickListen, + interactionSource = interactionSource, + indication = rememberRipple( + color = clickableColor(), + ), + onClick = { onClick(tab) }, + ) + } else { + Modifier.combinedClickable( + enabled = shouldClickListen, + interactionSource = interactionSource, + indication = rememberRipple( + color = clickableColor(), + ), + onLongClick = { onLongClick(tab) }, + onClick = { onClick(tab) }, ) + } - Column( + SwipeToDismiss( + state = dismissState, + enabled = !multiSelectionEnabled, + backgroundContent = { + DismissedTabBackground(dismissState.dismissDirection, RoundedCornerShape(0.dp)) + }, + ) { + Row( modifier = Modifier - .padding(horizontal = 16.dp) - .weight(weight = 1f) + .fillMaxWidth() + .background(WaterfoxTheme.colors.layer3) + .background(contentBackgroundColor) + .then(clickableModifier) + .padding(start = 16.dp, top = 8.dp, bottom = 8.dp) + .testTag(TabsTrayTestTag.tabItemRoot), + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = tab.content.title, - fontSize = 16.sp, - maxLines = 2, - color = WaterfoxTheme.colors.textPrimary, - ) - - Text( - text = tab.content.url.toShortUrl(), - fontSize = 12.sp, - color = WaterfoxTheme.colors.textSecondary, + Thumbnail( + tab = tab, + size = thumbnailSize, + storage = storage, + multiSelectionEnabled = multiSelectionEnabled, + isSelected = multiSelectionSelected, + onMediaIconClicked = { onMediaClick(it) }, + interactionSource = interactionSource, ) - } - if (!multiSelectionEnabled) { - IconButton( - onClick = { onCloseClick(tab) }, - modifier = Modifier.size(size = 24.dp), + Column( + modifier = Modifier + .padding(start = 12.dp) + .weight(weight = 1f), ) { - Icon( - painter = painterResource(id = R.drawable.mozac_ic_cross_20), - contentDescription = stringResource( - id = R.string.close_tab_title, - tab.content.title - ), - tint = WaterfoxTheme.colors.iconPrimary + Text( + text = tab.toDisplayTitle().take(MAX_URI_LENGTH), + color = WaterfoxTheme.colors.textPrimary, + style = WaterfoxTheme.typography.body1, + overflow = TextOverflow.Ellipsis, + maxLines = 2, ) + + Text( + text = tab.content.url.toShortUrl(), + color = WaterfoxTheme.colors.textSecondary, + style = WaterfoxTheme.typography.body2, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + } + + if (!multiSelectionEnabled) { + IconButton( + onClick = { onCloseClick(tab) }, + modifier = Modifier + .size(size = 48.dp) + .testTag(TabsTrayTestTag.tabItemClose), + ) { + Icon( + painter = painterResource(id = R.drawable.mozac_ic_cross_24), + contentDescription = stringResource( + id = R.string.close_tab_title, + tab.content.title, + ), + tint = WaterfoxTheme.colors.iconPrimary, + ) + } + } else { + Spacer(modifier = Modifier.size(48.dp)) } } } } +@Composable +private fun clickableColor() = when (isSystemInDarkTheme()) { + true -> PhotonColors.White + false -> PhotonColors.Black +} + @Composable private fun Thumbnail( tab: TabSessionState, @@ -138,18 +215,30 @@ private fun Thumbnail( storage: ThumbnailStorage, multiSelectionEnabled: Boolean, isSelected: Boolean, - onMediaIconClicked: ((TabSessionState) -> Unit) + onMediaIconClicked: ((TabSessionState) -> Unit), + interactionSource: MutableInteractionSource, ) { Box { TabThumbnail( tab = tab, size = size, storage = storage, - modifier = Modifier.size(width = 92.dp, height = 72.dp), + modifier = Modifier + .size(width = 92.dp, height = 72.dp) + .semantics(mergeDescendants = true) { + testTag = TabsTrayTestTag.tabItemThumbnail + }, contentDescription = stringResource(id = R.string.mozac_browser_tabstray_open_tab), ) if (isSelected) { + Box( + modifier = Modifier + .size(width = 92.dp, height = 72.dp) + .clip(RoundedCornerShape(4.dp)) + .background(WaterfoxTheme.colors.layerAccentNonOpaque), + ) + Card( modifier = Modifier .size(size = 40.dp) @@ -163,7 +252,7 @@ private fun Thumbnail( .matchParentSize() .padding(all = 8.dp), contentDescription = null, - tint = colorResource(id = R.color.mozac_ui_icons_fill) + tint = colorResource(id = R.color.mozac_ui_icons_fill), ) } } @@ -172,17 +261,17 @@ private fun Thumbnail( MediaImage( tab = tab, onMediaIconClicked = onMediaIconClicked, - modifier = Modifier.align(Alignment.TopEnd) + modifier = Modifier.align(Alignment.TopEnd), + interactionSource = interactionSource, ) } } } @Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@LightDarkPreview private fun TabListItemPreview() { - WaterfoxTheme(theme = Theme.getTheme()) { + WaterfoxTheme { TabListItem( tab = createTab(url = "www.mozilla.com", title = "Mozilla"), thumbnailSize = 108, @@ -190,16 +279,14 @@ private fun TabListItemPreview() { onCloseClick = {}, onMediaClick = {}, onClick = {}, - onLongClick = {}, ) } } @Composable -@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) -@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@LightDarkPreview private fun SelectedTabListItemPreview() { - WaterfoxTheme(theme = Theme.getTheme()) { + WaterfoxTheme { TabListItem( tab = createTab(url = "www.mozilla.com", title = "Mozilla"), thumbnailSize = 108, @@ -207,7 +294,6 @@ private fun SelectedTabListItemPreview() { onCloseClick = {}, onMediaClick = {}, onClick = {}, - onLongClick = {}, multiSelectionEnabled = true, multiSelectionSelected = true, ) diff --git a/app/src/main/java/net/waterfox/android/tabstray/CloseOnLastTabBinding.kt b/app/src/main/java/net/waterfox/android/tabstray/CloseOnLastTabBinding.kt index 240c385fd..cdf363a20 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/CloseOnLastTabBinding.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/CloseOnLastTabBinding.kt @@ -22,7 +22,7 @@ import mozilla.components.lib.state.helpers.AbstractBinding class CloseOnLastTabBinding( browserStore: BrowserStore, private val tabsTrayStore: TabsTrayStore, - private val navigationInteractor: NavigationInteractor + private val navigationInteractor: NavigationInteractor, ) : AbstractBinding(browserStore) { override suspend fun onState(flow: Flow) { flow.map { it } diff --git a/app/src/main/java/net/waterfox/android/tabstray/FloatingActionButtonBinding.kt b/app/src/main/java/net/waterfox/android/tabstray/FloatingActionButtonBinding.kt index cb7a76687..eab651821 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/FloatingActionButtonBinding.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/FloatingActionButtonBinding.kt @@ -10,17 +10,18 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import mozilla.components.lib.state.helpers.AbstractBinding import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged +import net.waterfox.android.tabstray.browser.TabsTrayFabInteractor import net.waterfox.android.R -import net.waterfox.android.tabstray.browser.BrowserTrayInteractor /** * A binding that show a FAB in tab tray used to open a new tab. */ @OptIn(ExperimentalCoroutinesApi::class) class FloatingActionButtonBinding( - private val store: TabsTrayStore, + store: TabsTrayStore, private val actionButton: ExtendedFloatingActionButton, - private val browserTrayInteractor: BrowserTrayInteractor + private val interactor: TabsTrayFabInteractor, + private val isSignedIn: Boolean, ) : AbstractBinding(store) { override suspend fun onState(flow: Flow) { @@ -28,7 +29,7 @@ class FloatingActionButtonBinding( .ifAnyChanged { state -> arrayOf( state.selectedPage, - state.syncing + state.syncing, ) } .collect { state -> @@ -45,7 +46,7 @@ class FloatingActionButtonBinding( contentDescription = context.getString(R.string.add_tab) setIconResource(R.drawable.ic_new) setOnClickListener { - browserTrayInteractor.onFabClicked(false) + interactor.onNormalTabsFabClicked() } } } @@ -57,29 +58,30 @@ class FloatingActionButtonBinding( contentDescription = context.getString(R.string.add_private_tab) setIconResource(R.drawable.ic_new) setOnClickListener { - browserTrayInteractor.onFabClicked(true) + interactor.onPrivateTabsFabClicked() } } } Page.SyncedTabs -> { - actionButton.apply { - setText( - when (syncing) { - true -> R.string.sync_syncing_in_progress - false -> R.string.tab_drawer_fab_sync - } - ) - contentDescription = context.getString(R.string.resync_button_content_description) - extend() - show() - setIconResource(R.drawable.ic_fab_sync) - setOnClickListener { - // Notify the store observers (one of which is the SyncedTabsFeature), that - // a sync was requested. - if (!syncing) { - store.dispatch(TabsTrayAction.SyncNow) + if (isSignedIn) { + actionButton.apply { + setText( + when (syncing) { + true -> R.string.sync_syncing_in_progress + false -> R.string.tab_drawer_fab_sync + }, + ) + contentDescription = + context.getString(R.string.resync_button_content_description) + extend() + show() + setIconResource(R.drawable.ic_fab_sync) + setOnClickListener { + interactor.onSyncedTabsFabClicked() } } + } else { + actionButton.hide() } } } diff --git a/app/src/main/java/net/waterfox/android/tabstray/MenuIntegration.kt b/app/src/main/java/net/waterfox/android/tabstray/MenuIntegration.kt index 0ccf617e9..565b811ff 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/MenuIntegration.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/MenuIntegration.kt @@ -9,24 +9,23 @@ import androidx.annotation.VisibleForTesting import com.google.android.material.tabs.TabLayout import mozilla.components.browser.menu.BrowserMenuBuilder import mozilla.components.browser.state.store.BrowserStore -import net.waterfox.android.utils.Do /** * A wrapper class that building the tabs tray menu that handles item clicks. */ class MenuIntegration( - @VisibleForTesting internal val context: Context, - @VisibleForTesting internal val browserStore: BrowserStore, - @VisibleForTesting internal val tabsTrayStore: TabsTrayStore, - @VisibleForTesting internal val tabLayout: TabLayout, - @VisibleForTesting internal val navigationInteractor: NavigationInteractor + @get:VisibleForTesting internal val context: Context, + @get:VisibleForTesting internal val browserStore: BrowserStore, + @get:VisibleForTesting internal val tabsTrayStore: TabsTrayStore, + @get:VisibleForTesting internal val tabLayout: TabLayout, + @get:VisibleForTesting internal val navigationInteractor: NavigationInteractor, ) { private val tabsTrayItemMenu by lazy { TabsTrayMenu( context = context, browserStore = browserStore, tabLayout = tabLayout, - onItemTapped = ::handleMenuClicked + onItemTapped = ::handleMenuClicked, ) } @@ -40,7 +39,7 @@ class MenuIntegration( @VisibleForTesting internal fun handleMenuClicked(item: TabsTrayMenu.Item) { - Do exhaustive when (item) { + when (item) { is TabsTrayMenu.Item.ShareAllTabs -> navigationInteractor.onShareTabsOfTypeClicked(isPrivateMode) is TabsTrayMenu.Item.OpenAccountSettings -> diff --git a/app/src/main/java/net/waterfox/android/tabstray/NavigationInteractor.kt b/app/src/main/java/net/waterfox/android/tabstray/NavigationInteractor.kt index ccf6429c9..5b5c9420a 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/NavigationInteractor.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/NavigationInteractor.kt @@ -4,28 +4,14 @@ package net.waterfox.android.tabstray -import android.content.Context import androidx.navigation.NavController -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import mozilla.components.browser.state.selector.getNormalOrPrivateTabs -import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.service.fxa.manager.FxaAccountManager -import net.waterfox.android.BrowserDirection -import net.waterfox.android.HomeActivity -import net.waterfox.android.collections.CollectionsDialog -import net.waterfox.android.collections.show -import net.waterfox.android.components.TabCollectionStorage -import net.waterfox.android.components.bookmarks.BookmarksUseCase import net.waterfox.android.components.accounts.WaterfoxFxAEntryPoint import net.waterfox.android.home.HomeFragment -import net.waterfox.android.tabstray.ext.getTabSessionState import net.waterfox.android.tabstray.ext.isActiveDownload -import java.util.* -import kotlin.coroutines.CoroutineContext -import mozilla.components.browser.storage.sync.Tab as SyncTab /** * An interactor that helps with navigating to different parts of the app from the tabs tray. @@ -43,11 +29,6 @@ interface NavigationInteractor { */ fun onAccountSettingsClicked() - /** - * Called when sharing a list of [TabSessionState]s. - */ - fun onShareTabs(tabs: Collection) - /** * Called when clicking the share tabs button. */ @@ -72,45 +53,19 @@ interface NavigationInteractor { * Called when opening the recently closed tabs menu button. */ fun onOpenRecentlyClosedClicked() - - /** - * Used when opening the add-to-collections user flow. - */ - fun onSaveToCollections(tabs: Collection) - - /** - * Used when adding [TabSessionState]s as bookmarks. - */ - fun onSaveToBookmarks(tabs: Collection) - - /** - * Called when clicking on a SyncedTab item. - */ - fun onSyncedTabClicked(tab: SyncTab) } /** * A default implementation of [NavigationInteractor]. */ -@Suppress("LongParameterList", "TooManyFunctions") +@Suppress("TooManyFunctions") class DefaultNavigationInteractor( - private val context: Context, - private val activity: HomeActivity, private val browserStore: BrowserStore, private val navController: NavController, private val dismissTabTray: () -> Unit, private val dismissTabTrayAndNavigateHome: (sessionId: String) -> Unit, - private val bookmarksUseCase: BookmarksUseCase, - private val tabsTrayStore: TabsTrayStore, - private val collectionStorage: TabCollectionStorage, - private val showCollectionSnackbar: ( - tabSize: Int, - isNewCollection: Boolean, - ) -> Unit, - private val showBookmarkSnackbar: (tabSize: Int) -> Unit, private val showCancelledDownloadWarning: (downloadCount: Int, tabId: String?, source: String?) -> Unit, private val accountManager: FxaAccountManager, - private val ioDispatcher: CoroutineContext ) : NavigationInteractor { override fun onTabTrayDismissed() { @@ -130,24 +85,14 @@ class DefaultNavigationInteractor( override fun onTabSettingsClicked() { navController.navigate( - TabsTrayFragmentDirections.actionGlobalTabSettingsFragment() + TabsTrayFragmentDirections.actionGlobalTabSettingsFragment(), ) } override fun onOpenRecentlyClosedClicked() { navController.navigate( - TabsTrayFragmentDirections.actionGlobalRecentlyClosed() - ) - } - - override fun onShareTabs(tabs: Collection) { - val data = tabs.map { - ShareData(url = it.content.url, title = it.content.title) - } - val directions = TabsTrayFragmentDirections.actionGlobalShareFragment( - data = data.toTypedArray() + TabsTrayFragmentDirections.actionGlobalRecentlyClosed(), ) - navController.navigate(directions) } override fun onShareTabsOfTypeClicked(private: Boolean) { @@ -156,7 +101,7 @@ class DefaultNavigationInteractor( ShareData(url = it.content.url, title = it.content.title) } val directions = TabsTrayFragmentDirections.actionGlobalShareFragment( - data = data.toTypedArray() + data = data.toTypedArray(), ) navController.navigate(directions) } @@ -187,43 +132,4 @@ class DefaultNavigationInteractor( } dismissTabTrayAndNavigateHome(sessionsToClose) } - - override fun onSaveToCollections(tabs: Collection) { - tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) - - CollectionsDialog( - storage = collectionStorage, - sessionList = browserStore.getTabSessionState(tabs), - onPositiveButtonClick = { id, isNewCollection -> - - // If collection is null, a new one was created. - id?.apply { - showCollectionSnackbar(tabs.size, isNewCollection) - } - }, - onNegativeButtonClick = {} - ).show(context) - } - - override fun onSaveToBookmarks(tabs: Collection) { - tabs.forEach { tab -> - // We don't combine the context with lifecycleScope so that our jobs are not cancelled - // if we leave the fragment, i.e. we still want the bookmarks to be added if the - // tabs tray closes before the job is done. - CoroutineScope(ioDispatcher).launch { - bookmarksUseCase.addBookmark(tab.content.url, tab.content.title) - } - } - - showBookmarkSnackbar(tabs.size) - } - - override fun onSyncedTabClicked(tab: SyncTab) { - dismissTabTray() - activity.openToBrowserAndLoad( - searchTermOrURL = tab.active().url, - newTab = true, - from = BrowserDirection.FromTabsTray - ) - } } diff --git a/app/src/main/java/net/waterfox/android/tabstray/SecureTabsTrayBinding.kt b/app/src/main/java/net/waterfox/android/tabstray/SecureTabsTrayBinding.kt index 64c56505c..049a9998e 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/SecureTabsTrayBinding.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/SecureTabsTrayBinding.kt @@ -8,6 +8,7 @@ import android.view.WindowManager import androidx.fragment.app.Fragment import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.map import mozilla.components.lib.state.helpers.AbstractBinding import mozilla.components.support.ktx.kotlinx.coroutines.flow.ifAnyChanged @@ -23,14 +24,14 @@ class SecureTabsTrayBinding( store: TabsTrayStore, private val settings: Settings, private val fragment: Fragment, - private val dialog: TabsTrayDialog + private val dialog: TabsTrayDialog, ) : AbstractBinding(store) { override suspend fun onState(flow: Flow) { flow.map { it } .ifAnyChanged { state -> arrayOf( - state.selectedPage + state.selectedPage, ) } .collect { state -> diff --git a/app/src/main/java/net/waterfox/android/tabstray/SyncedTabsController.kt b/app/src/main/java/net/waterfox/android/tabstray/SyncedTabsController.kt new file mode 100644 index 000000000..5f62c4d88 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/tabstray/SyncedTabsController.kt @@ -0,0 +1,19 @@ +/* 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 net.waterfox.android.tabstray + +import mozilla.components.browser.storage.sync.Tab + +/** + * Controller for handling any actions on synced tabs in the tabs tray. + */ +interface SyncedTabsController { + /** + * Handles a synced tab item click. + * + * @param tab The synced [Tab] that was clicked. + */ + fun handleSyncedTabClicked(tab: Tab) +} diff --git a/app/src/main/java/net/waterfox/android/tabstray/SyncedTabsInteractor.kt b/app/src/main/java/net/waterfox/android/tabstray/SyncedTabsInteractor.kt new file mode 100644 index 000000000..928ca7ae7 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/tabstray/SyncedTabsInteractor.kt @@ -0,0 +1,19 @@ +/* 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 net.waterfox.android.tabstray + +import mozilla.components.browser.storage.sync.Tab + +/** + * Interactor for responding to any actions on synced tabs in the tabs tray. + */ +interface SyncedTabsInteractor { + /** + * Invoked when the user clicks on a synced [Tab]. + * + * @param tab The synced [Tab] that was clicked. + */ + fun onSyncedTabClicked(tab: Tab) +} diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabCounterBinding.kt b/app/src/main/java/net/waterfox/android/tabstray/TabCounterBinding.kt index 0b650437c..200db459b 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/TabCounterBinding.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/TabCounterBinding.kt @@ -20,7 +20,7 @@ import mozilla.components.ui.tabcounter.TabCounter @OptIn(ExperimentalCoroutinesApi::class) class TabCounterBinding( store: BrowserStore, - private val counter: TabCounter + private val counter: TabCounter, ) : AbstractBinding(store) { override suspend fun onState(flow: Flow) { diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabSheetBehaviorManager.kt b/app/src/main/java/net/waterfox/android/tabstray/TabSheetBehaviorManager.kt index 406e719c3..2069d0c09 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/TabSheetBehaviorManager.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/TabSheetBehaviorManager.kt @@ -8,14 +8,25 @@ import android.content.res.Configuration import android.util.DisplayMetrics import android.view.View import androidx.annotation.VisibleForTesting -import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetBehavior.STATE_HIDDEN import mozilla.components.support.ktx.android.util.dpToPx @VisibleForTesting internal const val EXPANDED_OFFSET_IN_LANDSCAPE_DP = 0 + @VisibleForTesting internal const val EXPANDED_OFFSET_IN_PORTRAIT_DP = 40 +/** + * The default max dim value of the [TabsTrayDialog]. + */ +private const val DEFAULT_MAX_DIM = 0.6f + +/** + * The dim amount is 0.0 - 1.0 inclusive. We use this to convert the view element to the dim scale. + */ +private const val DIM_CONVERSION = 1000f + /** * Helper class for updating how the tray looks and behaves depending on app state / internal tray state. * @@ -23,25 +34,19 @@ import mozilla.components.support.ktx.android.util.dpToPx * @param orientation current Configuration.ORIENTATION_* of the device. * @param maxNumberOfTabs highest number of tabs in each tray page. * @param numberForExpandingTray limit depending on which the tray should be collapsed or expanded. - * @param navigationInteractor [NavigationInteractor] used for tray updates / navigation. * @param displayMetrics [DisplayMetrics] used for adapting resources to the current display. */ internal class TabSheetBehaviorManager( - private val behavior: BottomSheetBehavior, + private val behavior: BottomSheetBehavior, orientation: Int, private val maxNumberOfTabs: Int, private val numberForExpandingTray: Int, - navigationInteractor: NavigationInteractor, - private val displayMetrics: DisplayMetrics + private val displayMetrics: DisplayMetrics, ) { @VisibleForTesting internal var currentOrientation = orientation init { - behavior.addBottomSheetCallback( - TraySheetBehaviorCallback(behavior, navigationInteractor) - ) - val isInLandscape = isLandscape(orientation) updateBehaviorExpandedOffset(isInLandscape) updateBehaviorState(isInLandscape) @@ -82,21 +87,78 @@ internal class TabSheetBehaviorManager( internal fun isLandscape(orientation: Int) = Configuration.ORIENTATION_LANDSCAPE == orientation } -@VisibleForTesting internal class TraySheetBehaviorCallback( - @VisibleForTesting internal val behavior: BottomSheetBehavior, - @VisibleForTesting internal val trayInteractor: NavigationInteractor + @get:VisibleForTesting internal val behavior: BottomSheetBehavior, + @get:VisibleForTesting internal val trayInteractor: NavigationInteractor, + private val tabsTrayDialog: TabsTrayDialog, + private var newTabFab: View, ) : BottomSheetBehavior.BottomSheetCallback() { + @VisibleForTesting + var draggedLowestSheetTop: Int? = null + override fun onStateChanged(bottomSheet: View, newState: Int) { - if (newState == STATE_HIDDEN) { - trayInteractor.onTabTrayDismissed() - } else if (newState == BottomSheetBehavior.STATE_HALF_EXPANDED) { + when (newState) { + BottomSheetBehavior.STATE_HIDDEN -> trayInteractor.onTabTrayDismissed() + // We only support expanded and collapsed states. // Otherwise the tray may be left in an unusable state. See #14980. - behavior.state = STATE_HIDDEN + BottomSheetBehavior.STATE_HALF_EXPANDED -> + behavior.state = BottomSheetBehavior.STATE_HIDDEN + + // Reset the dragged lowest top value + BottomSheetBehavior.STATE_EXPANDED, BottomSheetBehavior.STATE_COLLAPSED -> { + draggedLowestSheetTop = null + } + + BottomSheetBehavior.STATE_DRAGGING, BottomSheetBehavior.STATE_SETTLING -> { + // Do nothing. Both cases are handled in the onSlide function. + } } } - override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit + override fun onSlide(bottomSheet: View, slideOffset: Float) { + setTabsTrayDialogDimAmount(bottomSheet.top) + setFabY(bottomSheet.top) + } + + private fun setTabsTrayDialogDimAmount(bottomSheetTop: Int) { + // Get any displayed bottom system bar. + val bottomSystemBarHeight = + ViewCompat.getRootWindowInsets(newTabFab) + ?.getInsets(WindowInsetsCompat.Type.systemBars())?.bottom ?: 0 + + // Calculate and convert delta to dim amount. + val appVisibleBottom = newTabFab.rootView.bottom - bottomSystemBarHeight + val trayTopAppBottomDelta = appVisibleBottom - bottomSheetTop + val convertedDimValue = trayTopAppBottomDelta / DIM_CONVERSION + + if (convertedDimValue < DEFAULT_MAX_DIM) { + tabsTrayDialog.window?.setDimAmount(convertedDimValue) + } + } + + private fun setFabY(bottomSheetTop: Int) { + if (behavior.state == BottomSheetBehavior.STATE_DRAGGING) { + draggedLowestSheetTop = getDraggedLowestSheetTop(bottomSheetTop) + + val dynamicSheetButtonDelta = newTabFab.top - draggedLowestSheetTop!! + newTabFab.y = getUpdatedFabY(bottomSheetTop, dynamicSheetButtonDelta) + } + + if (behavior.state == BottomSheetBehavior.STATE_SETTLING) { + val dynamicSheetButtonDelta = newTabFab.top - getDraggedLowestSheetTop(bottomSheetTop) + newTabFab.y = getUpdatedFabY(bottomSheetTop, dynamicSheetButtonDelta) + } + } + + private fun getDraggedLowestSheetTop(currentBottomSheetTop: Int) = + if (draggedLowestSheetTop == null || currentBottomSheetTop < draggedLowestSheetTop!!) { + currentBottomSheetTop + } else { + draggedLowestSheetTop!! + } + + private fun getUpdatedFabY(bottomSheetTop: Int, dynamicSheetButtonDelta: Int) = + (bottomSheetTop + dynamicSheetButtonDelta).toFloat() } diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTray.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTray.kt new file mode 100644 index 000000000..73b8d6b52 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTray.kt @@ -0,0 +1,630 @@ +/* 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:OptIn(ExperimentalFoundationApi::class) + +package net.waterfox.android.tabstray + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.rememberNestedScrollInteropConnection +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.ContentState +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.browser.storage.sync.TabEntry +import mozilla.components.browser.thumbnails.storage.ThumbnailStorage +import mozilla.components.lib.state.ext.observeAsComposableState +import net.waterfox.android.R +import net.waterfox.android.components.AppStore +import net.waterfox.android.components.appstate.AppState +import net.waterfox.android.compose.Divider +import net.waterfox.android.compose.annotation.LightDarkPreview +import net.waterfox.android.tabstray.ext.isNormalTab +import net.waterfox.android.tabstray.inactivetabs.InactiveTabsList +import net.waterfox.android.tabstray.syncedtabs.SyncedTabsList +import net.waterfox.android.tabstray.syncedtabs.SyncedTabsListItem +import net.waterfox.android.theme.WaterfoxTheme +import mozilla.components.browser.storage.sync.Tab as SyncTab + +/** + * Top-level UI for displaying the Tabs Tray feature. + * + * @param appStore [AppStore] used to listen for changes to [AppState]. + * @param browserStore [BrowserStore] used to listen for changes to [BrowserState]. + * @param tabsTrayStore [TabsTrayStore] used to listen for changes to [TabsTrayState]. + * @param storage [ThumbnailStorage] to obtain tab thumbnail bitmaps from. + * @param displayTabsInGrid Whether the normal and private tabs should be displayed in a grid. + * @param isInDebugMode True for debug variant or if secret menu is enabled for this session. + * @param shouldShowTabAutoCloseBanner Whether the tab auto closer banner should be displayed. + * @param shouldShowInactiveTabsAutoCloseDialog Whether the inactive tabs auto close dialog should be displayed. + * @param onTabPageClick Invoked when the user clicks on the Normal, Private, or Synced tabs page button. + * @param onTabClose Invoked when the user clicks to close a tab. + * @param onTabMediaClick Invoked when the user interacts with a tab's media controls. + * @param onTabClick Invoked when the user clicks on a tab. + * @param onTabLongClick Invoked when the user long clicks a tab. + * @param onInactiveTabsHeaderClick Invoked when the user clicks on the inactive tabs section header. + * @param onDeleteAllInactiveTabsClick Invoked when the user clicks on the delete all inactive tabs button. + * @param onInactiveTabsAutoCloseDialogShown Invoked when the inactive tabs auto close dialog + * is presented to the user. + * @param onInactiveTabAutoCloseDialogCloseButtonClick Invoked when the user clicks on the inactive + * tab auto close dialog's dismiss button. + * @param onEnableInactiveTabAutoCloseClick Invoked when the user clicks on the inactive tab auto + * close dialog's enable button. + * @param onInactiveTabClick Invoked when the user clicks on an inactive tab. + * @param onInactiveTabClose Invoked when the user clicks on an inactive tab's close button. + * @param onSyncedTabClick Invoked when the user clicks on a synced tab. + * @param onSaveToCollectionClick Invoked when the user clicks on the save to collection button from + * the multi select banner. + * @param onShareSelectedTabsClick Invoked when the user clicks on the share button from the + * multi select banner. + * @param onShareAllTabsClick Invoked when the user clicks on the share all tabs banner menu item. + * @param onTabSettingsClick Invoked when the user clicks on the tab settings banner menu item. + * @param onRecentlyClosedClick Invoked when the user clicks on the recently closed banner menu item. + * @param onAccountSettingsClick Invoked when the user clicks on the account settings banner menu item. + * @param onDeleteAllTabsClick Invoked when the user clicks on the close all tabs banner menu item. + * @param onBookmarkSelectedTabsClick Invoked when the user clicks on the bookmark banner menu item. + * @param onDeleteSelectedTabsClick Invoked when the user clicks on the close selected tabs banner menu item. + * @param onForceSelectedTabsAsInactiveClick Invoked when the user clicks on the make inactive banner menu item. + * @param onTabsTrayDismiss Invoked when accessibility services or UI automation requests dismissal. + * @param onTabAutoCloseBannerViewOptionsClick Invoked when the user clicks to view the auto close options. + * @param onTabAutoCloseBannerDismiss Invoked when the user clicks to dismiss the auto close banner. + * @param onTabAutoCloseBannerShown Invoked when the auto close banner has been shown to the user. + * @param onMove Invoked after the drag and drop gesture completed. Swaps positions of two tabs. + */ +@OptIn(ExperimentalFoundationApi::class) +@Suppress("LongMethod", "LongParameterList", "ComplexMethod") +@Composable +fun TabsTray( + appStore: AppStore, + browserStore: BrowserStore, + tabsTrayStore: TabsTrayStore, + storage: ThumbnailStorage, + displayTabsInGrid: Boolean, + isInDebugMode: Boolean, + shouldShowTabAutoCloseBanner: Boolean, + shouldShowInactiveTabsAutoCloseDialog: (Int) -> Boolean, + onTabPageClick: (Page) -> Unit, + onTabClose: (TabSessionState) -> Unit, + onTabMediaClick: (TabSessionState) -> Unit, + onTabClick: (TabSessionState) -> Unit, + onTabLongClick: (TabSessionState) -> Unit, + onInactiveTabsHeaderClick: (Boolean) -> Unit, + onDeleteAllInactiveTabsClick: () -> Unit, + onInactiveTabsAutoCloseDialogShown: () -> Unit, + onInactiveTabAutoCloseDialogCloseButtonClick: () -> Unit, + onEnableInactiveTabAutoCloseClick: () -> Unit, + onInactiveTabClick: (TabSessionState) -> Unit, + onInactiveTabClose: (TabSessionState) -> Unit, + onSyncedTabClick: (SyncTab) -> Unit, + onSaveToCollectionClick: () -> Unit, + onShareSelectedTabsClick: () -> Unit, + onShareAllTabsClick: () -> Unit, + onTabSettingsClick: () -> Unit, + onRecentlyClosedClick: () -> Unit, + onAccountSettingsClick: () -> Unit, + onDeleteAllTabsClick: () -> Unit, + onBookmarkSelectedTabsClick: () -> Unit, + onDeleteSelectedTabsClick: () -> Unit, + onForceSelectedTabsAsInactiveClick: () -> Unit, + onTabsTrayDismiss: () -> Unit, + onTabAutoCloseBannerViewOptionsClick: () -> Unit, + onTabAutoCloseBannerDismiss: () -> Unit, + onTabAutoCloseBannerShown: () -> Unit, + onMove: (String, String?, Boolean) -> Unit, +) { + val multiselectMode = tabsTrayStore + .observeAsComposableState { state -> state.mode }.value ?: TabsTrayState.Mode.Normal + val selectedPage = tabsTrayStore + .observeAsComposableState { state -> state.selectedPage }.value ?: Page.NormalTabs + val pagerState = + rememberPagerState(initialPage = selectedPage.ordinal, pageCount = { Page.values().size }) + val isInMultiSelectMode = multiselectMode is TabsTrayState.Mode.Select + + val shapeModifier = if (isInMultiSelectMode) { + Modifier + } else { + Modifier.clip(RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) + } + + LaunchedEffect(selectedPage) { + pagerState.animateScrollToPage(selectedPage.ordinal) + } + + Column( + modifier = Modifier + .fillMaxSize() + .then(shapeModifier) + .background(WaterfoxTheme.colors.layer1) + .testTag(TabsTrayTestTag.tabsTray), + ) { + Box(modifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection())) { + TabsTrayBanner( + tabsTrayStore = tabsTrayStore, + isInDebugMode = isInDebugMode, + shouldShowTabAutoCloseBanner = shouldShowTabAutoCloseBanner, + onTabPageIndicatorClicked = onTabPageClick, + onSaveToCollectionClick = onSaveToCollectionClick, + onShareSelectedTabsClick = onShareSelectedTabsClick, + onShareAllTabsClick = onShareAllTabsClick, + onTabSettingsClick = onTabSettingsClick, + onRecentlyClosedClick = onRecentlyClosedClick, + onAccountSettingsClick = onAccountSettingsClick, + onDeleteAllTabsClick = onDeleteAllTabsClick, + onBookmarkSelectedTabsClick = onBookmarkSelectedTabsClick, + onDeleteSelectedTabsClick = onDeleteSelectedTabsClick, + onForceSelectedTabsAsInactiveClick = onForceSelectedTabsAsInactiveClick, + onDismissClick = onTabsTrayDismiss, + onTabAutoCloseBannerViewOptionsClick = onTabAutoCloseBannerViewOptionsClick, + onTabAutoCloseBannerDismiss = onTabAutoCloseBannerDismiss, + onTabAutoCloseBannerShown = onTabAutoCloseBannerShown, + ) + } + + Divider() + + Box(modifier = Modifier.fillMaxSize()) { + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = pagerState, + beyondBoundsPageCount = 2, + userScrollEnabled = false, + ) { position -> + when (Page.positionToPage(position)) { + Page.NormalTabs -> { + NormalTabsPage( + appStore = appStore, + browserStore = browserStore, + tabsTrayStore = tabsTrayStore, + storage = storage, + displayTabsInGrid = displayTabsInGrid, + onTabClose = onTabClose, + onTabMediaClick = onTabMediaClick, + onTabClick = onTabClick, + onTabLongClick = onTabLongClick, + shouldShowInactiveTabsAutoCloseDialog = shouldShowInactiveTabsAutoCloseDialog, + onInactiveTabsHeaderClick = onInactiveTabsHeaderClick, + onDeleteAllInactiveTabsClick = onDeleteAllInactiveTabsClick, + onInactiveTabsAutoCloseDialogShown = onInactiveTabsAutoCloseDialogShown, + onInactiveTabAutoCloseDialogCloseButtonClick = onInactiveTabAutoCloseDialogCloseButtonClick, + onEnableInactiveTabAutoCloseClick = onEnableInactiveTabAutoCloseClick, + onInactiveTabClick = onInactiveTabClick, + onInactiveTabClose = onInactiveTabClose, + onMove = onMove, + ) + } + + Page.PrivateTabs -> { + PrivateTabsPage( + browserStore = browserStore, + tabsTrayStore = tabsTrayStore, + storage = storage, + displayTabsInGrid = displayTabsInGrid, + onTabClose = onTabClose, + onTabMediaClick = onTabMediaClick, + onTabClick = onTabClick, + onTabLongClick = onTabLongClick, + onMove = onMove, + ) + } + + Page.SyncedTabs -> { + SyncedTabsPage( + tabsTrayStore = tabsTrayStore, + onTabClick = onSyncedTabClick, + ) + } + } + } + } + } +} + +@Composable +@Suppress("LongParameterList") +private fun NormalTabsPage( + appStore: AppStore, + browserStore: BrowserStore, + tabsTrayStore: TabsTrayStore, + storage: ThumbnailStorage, + displayTabsInGrid: Boolean, + onTabClose: (TabSessionState) -> Unit, + onTabMediaClick: (TabSessionState) -> Unit, + onTabClick: (TabSessionState) -> Unit, + onTabLongClick: (TabSessionState) -> Unit, + shouldShowInactiveTabsAutoCloseDialog: (Int) -> Boolean, + onInactiveTabsHeaderClick: (Boolean) -> Unit, + onDeleteAllInactiveTabsClick: () -> Unit, + onInactiveTabsAutoCloseDialogShown: () -> Unit, + onInactiveTabAutoCloseDialogCloseButtonClick: () -> Unit, + onEnableInactiveTabAutoCloseClick: () -> Unit, + onInactiveTabClick: (TabSessionState) -> Unit, + onInactiveTabClose: (TabSessionState) -> Unit, + onMove: (String, String?, Boolean) -> Unit, +) { + val inactiveTabsExpanded = appStore + .observeAsComposableState { state -> state.inactiveTabsExpanded }.value ?: false + val selectedTabId = browserStore + .observeAsComposableState { state -> state.selectedTabId }.value + val normalTabs = tabsTrayStore + .observeAsComposableState { state -> state.normalTabs }.value ?: emptyList() + val inactiveTabs = tabsTrayStore + .observeAsComposableState { state -> state.inactiveTabs }.value ?: emptyList() + val selectionMode = tabsTrayStore + .observeAsComposableState { state -> state.mode }.value ?: TabsTrayState.Mode.Normal + + if (normalTabs.isNotEmpty() || inactiveTabs.isNotEmpty()) { + val showInactiveTabsAutoCloseDialog = + shouldShowInactiveTabsAutoCloseDialog(inactiveTabs.size) + var showAutoCloseDialog by remember { mutableStateOf(showInactiveTabsAutoCloseDialog) } + + val optionalInactiveTabsHeader: (@Composable () -> Unit)? = if (inactiveTabs.isEmpty()) { + null + } else { + { + InactiveTabsList( + inactiveTabs = inactiveTabs, + expanded = inactiveTabsExpanded, + showAutoCloseDialog = showAutoCloseDialog, + onHeaderClick = onInactiveTabsHeaderClick, + onDeleteAllButtonClick = onDeleteAllInactiveTabsClick, + onAutoCloseDismissClick = { + onInactiveTabAutoCloseDialogCloseButtonClick() + showAutoCloseDialog = !showAutoCloseDialog + }, + onEnableAutoCloseClick = { + onEnableInactiveTabAutoCloseClick() + showAutoCloseDialog = !showAutoCloseDialog + }, + onTabClick = onInactiveTabClick, + onTabCloseClick = onInactiveTabClose, + ) + } + } + + if (showInactiveTabsAutoCloseDialog) { + onInactiveTabsAutoCloseDialogShown() + } + + TabLayout( + tabs = normalTabs, + storage = storage, + displayTabsInGrid = displayTabsInGrid, + selectedTabId = selectedTabId, + selectionMode = selectionMode, + modifier = Modifier.testTag(TabsTrayTestTag.normalTabsList), + onTabClose = onTabClose, + onTabMediaClick = onTabMediaClick, + onTabClick = onTabClick, + onTabLongClick = onTabLongClick, + header = optionalInactiveTabsHeader, + onTabDragStart = { tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) }, + onMove = onMove, + ) + } else { + EmptyTabPage(isPrivate = false) + } +} + +@Composable +@Suppress("LongParameterList") +private fun PrivateTabsPage( + browserStore: BrowserStore, + tabsTrayStore: TabsTrayStore, + storage: ThumbnailStorage, + displayTabsInGrid: Boolean, + onTabClose: (TabSessionState) -> Unit, + onTabMediaClick: (TabSessionState) -> Unit, + onTabClick: (TabSessionState) -> Unit, + onTabLongClick: (TabSessionState) -> Unit, + onMove: (String, String?, Boolean) -> Unit, +) { + val selectedTabId = browserStore + .observeAsComposableState { state -> state.selectedTabId }.value + val privateTabs = tabsTrayStore + .observeAsComposableState { state -> state.privateTabs }.value ?: emptyList() + val selectionMode = tabsTrayStore + .observeAsComposableState { state -> state.mode }.value ?: TabsTrayState.Mode.Normal + + if (privateTabs.isNotEmpty()) { + TabLayout( + tabs = privateTabs, + storage = storage, + displayTabsInGrid = displayTabsInGrid, + selectedTabId = selectedTabId, + selectionMode = selectionMode, + modifier = Modifier.testTag(TabsTrayTestTag.privateTabsList), + onTabClose = onTabClose, + onTabMediaClick = onTabMediaClick, + onTabClick = onTabClick, + onTabLongClick = onTabLongClick, + onTabDragStart = { + // Because we don't currently support selection mode for private tabs, + // there's no need to exit selection mode when dragging tabs. + }, + onMove = onMove, + ) + } else { + EmptyTabPage(isPrivate = true) + } +} + +@Composable +private fun SyncedTabsPage( + tabsTrayStore: TabsTrayStore, + onTabClick: (SyncTab) -> Unit, +) { + val syncedTabs = tabsTrayStore + .observeAsComposableState { state -> state.syncedTabs }.value ?: emptyList() + + SyncedTabsList( + syncedTabs = syncedTabs, + onTabClick = onTabClick, + ) +} + +@Composable +private fun EmptyTabPage(isPrivate: Boolean) { + val testTag: String + val emptyTextId: Int + if (isPrivate) { + testTag = TabsTrayTestTag.emptyPrivateTabsList + emptyTextId = R.string.no_private_tabs_description + } else { + testTag = TabsTrayTestTag.emptyNormalTabsList + emptyTextId = R.string.no_open_tabs_description + } + + Box( + modifier = Modifier + .fillMaxSize() + .testTag(testTag), + ) { + Text( + text = stringResource(id = emptyTextId), + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 80.dp), + color = WaterfoxTheme.colors.textSecondary, + style = WaterfoxTheme.typography.body1, + ) + } +} + +@LightDarkPreview +@Composable +private fun TabsTrayPreview() { + val tabs = generateFakeTabsList() + TabsTrayPreviewRoot( + displayTabsInGrid = false, + selectedTabId = tabs[0].id, + normalTabs = tabs, + privateTabs = generateFakeTabsList( + tabCount = 7, + isPrivate = true, + ), + syncedTabs = generateFakeSyncedTabsList(), + ) +} + +@Suppress("MagicNumber") +@LightDarkPreview +@Composable +private fun TabsTrayMultiSelectPreview() { + val tabs = generateFakeTabsList() + TabsTrayPreviewRoot( + selectedTabId = tabs[0].id, + mode = TabsTrayState.Mode.Select(tabs.take(4).toSet()), + normalTabs = tabs, + ) +} + +@LightDarkPreview +@Composable +private fun TabsTrayInactiveTabsPreview() { + TabsTrayPreviewRoot( + normalTabs = generateFakeTabsList(tabCount = 3), + inactiveTabs = generateFakeTabsList(), + inactiveTabsExpanded = true, + showInactiveTabsAutoCloseDialog = true, + ) +} + +@LightDarkPreview +@Composable +private fun TabsTrayPrivateTabsPreview() { + TabsTrayPreviewRoot( + selectedPage = Page.PrivateTabs, + privateTabs = generateFakeTabsList(isPrivate = true), + ) +} + +@LightDarkPreview +@Composable +private fun TabsTraySyncedTabsPreview() { + TabsTrayPreviewRoot( + selectedPage = Page.SyncedTabs, + syncedTabs = generateFakeSyncedTabsList(deviceCount = 3), + ) +} + +@LightDarkPreview +@Composable +private fun TabsTrayAutoCloseBannerPreview() { + TabsTrayPreviewRoot( + normalTabs = generateFakeTabsList(), + showTabAutoCloseBanner = true, + ) +} + +@Suppress("LongMethod") +@Composable +private fun TabsTrayPreviewRoot( + displayTabsInGrid: Boolean = true, + selectedPage: Page = Page.NormalTabs, + selectedTabId: String? = null, + mode: TabsTrayState.Mode = TabsTrayState.Mode.Normal, + normalTabs: List = emptyList(), + inactiveTabs: List = emptyList(), + privateTabs: List = emptyList(), + syncedTabs: List = emptyList(), + inactiveTabsExpanded: Boolean = false, + showInactiveTabsAutoCloseDialog: Boolean = false, + showTabAutoCloseBanner: Boolean = false, +) { + var selectedPageState by remember { mutableStateOf(selectedPage) } + val normalTabsState = remember { normalTabs.toMutableStateList() } + val inactiveTabsState = remember { inactiveTabs.toMutableStateList() } + val privateTabsState = remember { privateTabs.toMutableStateList() } + val syncedTabsState = remember { syncedTabs.toMutableStateList() } + var inactiveTabsExpandedState by remember { mutableStateOf(inactiveTabsExpanded) } + var showInactiveTabsAutoCloseDialogState by remember { mutableStateOf(showInactiveTabsAutoCloseDialog) } + + val appStore = AppStore( + initialState = AppState( + inactiveTabsExpanded = inactiveTabsExpandedState, + ), + ) + val browserStore = BrowserStore( + initialState = BrowserState( + tabs = normalTabs + privateTabs, + selectedTabId = selectedTabId, + ), + ) + val tabsTrayStore = TabsTrayStore( + initialState = TabsTrayState( + selectedPage = selectedPageState, + mode = mode, + inactiveTabs = inactiveTabsState, + normalTabs = normalTabsState, + privateTabs = privateTabsState, + syncedTabs = syncedTabsState, + ), + ) + + WaterfoxTheme { + TabsTray( + appStore = appStore, + browserStore = browserStore, + tabsTrayStore = tabsTrayStore, + storage = ThumbnailStorage(LocalContext.current), + displayTabsInGrid = displayTabsInGrid, + isInDebugMode = false, + shouldShowInactiveTabsAutoCloseDialog = { true }, + shouldShowTabAutoCloseBanner = showTabAutoCloseBanner, + onTabPageClick = { page -> + selectedPageState = page + }, + onTabClose = { tab -> + if (tab.isNormalTab()) { + normalTabsState.remove(tab) + } else { + privateTabsState.remove(tab) + } + }, + onTabMediaClick = {}, + onTabClick = { tab -> + when (tabsTrayStore.state.mode) { + TabsTrayState.Mode.Normal -> {} + is TabsTrayState.Mode.Select -> { + if (tabsTrayStore.state.mode.selectedTabs.contains(tab)) { + tabsTrayStore.dispatch(TabsTrayAction.RemoveSelectTab(tab)) + } else { + tabsTrayStore.dispatch(TabsTrayAction.AddSelectTab(tab)) + } + } + } + }, + onTabLongClick = { tab -> + tabsTrayStore.dispatch(TabsTrayAction.AddSelectTab(tab)) + }, + onInactiveTabsHeaderClick = { + inactiveTabsExpandedState = !inactiveTabsExpandedState + }, + onDeleteAllInactiveTabsClick = inactiveTabsState::clear, + onInactiveTabsAutoCloseDialogShown = {}, + onInactiveTabAutoCloseDialogCloseButtonClick = { + showInactiveTabsAutoCloseDialogState = !showInactiveTabsAutoCloseDialogState + }, + onEnableInactiveTabAutoCloseClick = { + showInactiveTabsAutoCloseDialogState = !showInactiveTabsAutoCloseDialogState + }, + onInactiveTabClick = {}, + onInactiveTabClose = inactiveTabsState::remove, + onSyncedTabClick = {}, + onSaveToCollectionClick = {}, + onShareSelectedTabsClick = {}, + onShareAllTabsClick = {}, + onTabSettingsClick = {}, + onRecentlyClosedClick = {}, + onAccountSettingsClick = {}, + onDeleteAllTabsClick = {}, + onDeleteSelectedTabsClick = {}, + onBookmarkSelectedTabsClick = {}, + onForceSelectedTabsAsInactiveClick = {}, + onTabsTrayDismiss = {}, + onTabAutoCloseBannerViewOptionsClick = {}, + onTabAutoCloseBannerDismiss = {}, + onTabAutoCloseBannerShown = {}, + onMove = { _, _, _ -> }, + ) + } +} + +private fun generateFakeTabsList(tabCount: Int = 10, isPrivate: Boolean = false): List = + List(tabCount) { index -> + TabSessionState( + id = "tabId$index-$isPrivate", + content = ContentState( + url = "www.mozilla.com", + private = isPrivate, + ), + ) + } + +private fun generateFakeSyncedTabsList(deviceCount: Int = 1): List = + List(deviceCount) { index -> + SyncedTabsListItem.DeviceSection( + displayName = "Device $index", + tabs = listOf( + generateFakeSyncedTab("Mozilla", "www.mozilla.org"), + generateFakeSyncedTab("Google", "www.google.com"), + generateFakeSyncedTab("", "www.google.com"), + ), + ) + } + +private fun generateFakeSyncedTab(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/net/waterfox/android/tabstray/TabsTrayBanner.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayBanner.kt new file mode 100644 index 000000000..c10f55793 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayBanner.kt @@ -0,0 +1,487 @@ +/* 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 net.waterfox.android.tabstray + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.Tab +import androidx.compose.material.TabRow +import androidx.compose.material.Text +import androidx.compose.material.ripple.LocalRippleTheme +import androidx.compose.material.ripple.RippleAlpha +import androidx.compose.material.ripple.RippleTheme +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import mozilla.components.browser.state.state.ContentState +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.lib.state.ext.observeAsComposableState +import mozilla.components.ui.tabcounter.TabCounter +import net.waterfox.android.R +import net.waterfox.android.compose.Banner +import net.waterfox.android.compose.BottomSheetHandle +import net.waterfox.android.compose.ContextualMenu +import net.waterfox.android.compose.Divider +import net.waterfox.android.compose.MenuItem +import net.waterfox.android.compose.TabCounter +import net.waterfox.android.compose.annotation.LightDarkPreview +import net.waterfox.android.tabstray.ext.getMenuItems +import net.waterfox.android.theme.WaterfoxTheme +import kotlin.math.max + +private val ICON_SIZE = 24.dp +private const val MAX_WIDTH_TAB_ROW_PERCENT = 0.5f +private const val BOTTOM_SHEET_HANDLE_WIDTH_PERCENT = 0.1f +private const val TAB_COUNT_SHOW_CFR = 6 + +/** + * Top-level UI for displaying the banner in [TabsTray]. + * + * @param tabsTrayStore [TabsTrayStore] used to listen for changes to [TabsTrayState]. + * @param isInDebugMode True for debug variant or if secret menu is enabled for this session. + * @param shouldShowTabAutoCloseBanner Whether the tab auto closer banner should be displayed. + * @param onTabPageIndicatorClicked Invoked when the user clicks on a tab page indicator. + * @param onSaveToCollectionClick Invoked when the user clicks on the save to collection button from + * the multi select banner. + * @param onShareSelectedTabsClick Invoked when the user clicks on the share button from the multi select banner. + * @param onShareAllTabsClick Invoked when the user clicks on the share menu item. + * @param onTabSettingsClick Invoked when the user clicks on the tab settings menu item. + * @param onRecentlyClosedClick Invoked when the user clicks on the recently closed tabs menu item. + * @param onAccountSettingsClick Invoked when the user clicks on the account settings menu item. + * @param onDeleteAllTabsClick Invoked when user interacts with the close all tabs menu item. + * @param onDeleteSelectedTabsClick Invoked when user interacts with the close menu item. + * @param onBookmarkSelectedTabsClick Invoked when user interacts with the bookmark menu item. + * @param onForceSelectedTabsAsInactiveClick Invoked when user interacts with the make inactive menu item. + * @param onDismissClick Invoked when accessibility services or UI automation requests dismissal. + * @param onTabAutoCloseBannerViewOptionsClick Invoked when the user clicks to view the auto close options. + * @param onTabAutoCloseBannerDismiss Invoked when the user clicks to dismiss the auto close banner. + * @param onTabAutoCloseBannerShown Invoked when the auto close banner has been shown to the user. + */ +@Suppress("LongParameterList") +@Composable +fun TabsTrayBanner( + tabsTrayStore: TabsTrayStore, + isInDebugMode: Boolean, + shouldShowTabAutoCloseBanner: Boolean, + onTabPageIndicatorClicked: (Page) -> Unit, + onSaveToCollectionClick: () -> Unit, + onShareSelectedTabsClick: () -> Unit, + onShareAllTabsClick: () -> Unit, + onTabSettingsClick: () -> Unit, + onRecentlyClosedClick: () -> Unit, + onAccountSettingsClick: () -> Unit, + onDeleteAllTabsClick: () -> Unit, + onDeleteSelectedTabsClick: () -> Unit, + onBookmarkSelectedTabsClick: () -> Unit, + onForceSelectedTabsAsInactiveClick: () -> Unit, + onDismissClick: () -> Unit, + onTabAutoCloseBannerViewOptionsClick: () -> Unit, + onTabAutoCloseBannerDismiss: () -> Unit, + onTabAutoCloseBannerShown: () -> Unit, +) { + val normalTabCount = tabsTrayStore.observeAsComposableState { state -> + state.normalTabs.size + state.inactiveTabs.size + }.value ?: 0 + val privateTabCount = tabsTrayStore + .observeAsComposableState { state -> state.privateTabs.size }.value ?: 0 + val multiselectMode = tabsTrayStore + .observeAsComposableState { state -> state.mode }.value ?: TabsTrayState.Mode.Normal + val selectedPage = tabsTrayStore + .observeAsComposableState { state -> state.selectedPage }.value ?: Page.NormalTabs + val showTabAutoCloseBanner = tabsTrayStore.observeAsComposableState { state -> + shouldShowTabAutoCloseBanner && max(state.normalTabs.size, state.privateTabs.size) >= TAB_COUNT_SHOW_CFR + }.value ?: false + var hasAcknowledgedBanner by remember { mutableStateOf(false) } + + val menuItems = multiselectMode.getMenuItems( + resources = LocalContext.current.resources, + shouldShowInactiveButton = isInDebugMode, + onBookmarkSelectedTabsClick = onBookmarkSelectedTabsClick, + onCloseSelectedTabsClick = onDeleteSelectedTabsClick, + onMakeSelectedTabsInactive = onForceSelectedTabsAsInactiveClick, + + selectedPage = selectedPage, + normalTabCount = normalTabCount, + privateTabCount = privateTabCount, + onTabSettingsClick = onTabSettingsClick, + onRecentlyClosedClick = onRecentlyClosedClick, + onEnterMultiselectModeClick = { tabsTrayStore.dispatch(TabsTrayAction.EnterSelectMode) }, + onShareAllTabsClick = onShareAllTabsClick, + onDeleteAllTabsClick = onDeleteAllTabsClick, + onAccountSettingsClick = onAccountSettingsClick, + ) + + Column { + if (multiselectMode is TabsTrayState.Mode.Select) { + MultiSelectBanner( + menuItems = menuItems, + selectedTabCount = multiselectMode.selectedTabs.size, + onExitSelectModeClick = { tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) }, + onSaveToCollectionsClick = onSaveToCollectionClick, + onShareSelectedTabs = onShareSelectedTabsClick, + ) + } else { + SingleSelectBanner( + menuItems = menuItems, + selectedPage = selectedPage, + normalTabCount = normalTabCount, + onTabPageIndicatorClicked = onTabPageIndicatorClicked, + onDismissClick = onDismissClick, + ) + } + + if (!hasAcknowledgedBanner && showTabAutoCloseBanner) { + onTabAutoCloseBannerShown() + + Divider() + + Banner( + message = stringResource(id = R.string.tab_tray_close_tabs_banner_message), + button1Text = stringResource(id = R.string.tab_tray_close_tabs_banner_negative_button_text), + button2Text = stringResource(id = R.string.tab_tray_close_tabs_banner_positive_button_text), + onButton1Click = { + hasAcknowledgedBanner = true + onTabAutoCloseBannerViewOptionsClick() + }, + onButton2Click = { + hasAcknowledgedBanner = true + onTabAutoCloseBannerDismiss() + }, + ) + } + } +} + +@Suppress("LongMethod") +@Composable +private fun SingleSelectBanner( + menuItems: List, + selectedPage: Page, + normalTabCount: Int, + onTabPageIndicatorClicked: (Page) -> Unit, + onDismissClick: () -> Unit, +) { + val selectedColor = WaterfoxTheme.colors.iconActive + val inactiveColor = WaterfoxTheme.colors.iconPrimaryInactive + var showMenu by remember { mutableStateOf(false) } + + Column(modifier = Modifier.background(color = WaterfoxTheme.colors.layer1)) { + Spacer(modifier = Modifier.height(dimensionResource(id = R.dimen.bottom_sheet_handle_top_margin))) + + BottomSheetHandle( + onRequestDismiss = onDismissClick, + contentDescription = stringResource(R.string.a11y_action_label_collapse), + modifier = Modifier + .fillMaxWidth(BOTTOM_SHEET_HANDLE_WIDTH_PERCENT) + .align(Alignment.CenterHorizontally) + .testTag(TabsTrayTestTag.bannerHandle), + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .height(80.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + CompositionLocalProvider(LocalRippleTheme provides DisabledRippleTheme) { + TabRow( + selectedTabIndex = selectedPage.ordinal, + modifier = Modifier.fillMaxWidth(MAX_WIDTH_TAB_ROW_PERCENT), + backgroundColor = Color.Transparent, + contentColor = selectedColor, + divider = {}, + ) { + Tab( + selected = selectedPage == Page.NormalTabs, + onClick = { onTabPageIndicatorClicked(Page.NormalTabs) }, + modifier = Modifier + .fillMaxHeight() + .testTag(TabsTrayTestTag.normalTabsPageButton), + selectedContentColor = selectedColor, + unselectedContentColor = inactiveColor, + ) { + TabCounter(tabCount = normalTabCount) + } + + Tab( + selected = selectedPage == Page.PrivateTabs, + onClick = { onTabPageIndicatorClicked(Page.PrivateTabs) }, + modifier = Modifier + .fillMaxHeight() + .testTag(TabsTrayTestTag.privateTabsPageButton), + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_private_browsing), + contentDescription = stringResource(id = R.string.tabs_header_private_tabs_title), + ) + }, + selectedContentColor = selectedColor, + unselectedContentColor = inactiveColor, + ) + + Tab( + selected = selectedPage == Page.SyncedTabs, + onClick = { onTabPageIndicatorClicked(Page.SyncedTabs) }, + modifier = Modifier + .fillMaxHeight() + .testTag(TabsTrayTestTag.syncedTabsPageButton), + icon = { + Icon( + painter = painterResource(id = R.drawable.ic_synced_tabs), + contentDescription = stringResource(id = R.string.tabs_header_synced_tabs_title), + ) + }, + selectedContentColor = selectedColor, + unselectedContentColor = inactiveColor, + ) + } + } + + Spacer(modifier = Modifier.weight(1.0f)) + + IconButton( + onClick = { showMenu = true }, + modifier = Modifier + .align(Alignment.CenterVertically) + .testTag(TabsTrayTestTag.threeDotButton), + ) { + ContextualMenu( + menuItems = menuItems, + showMenu = showMenu, + offset = DpOffset(x = 0.dp, y = -ICON_SIZE), + onDismissRequest = { showMenu = false }, + ) + Icon( + painter = painterResource(R.drawable.ic_menu), + contentDescription = stringResource(id = R.string.open_tabs_menu), + tint = WaterfoxTheme.colors.iconPrimary, + ) + } + } + } +} + +/** + * Banner displayed in multi select mode. + * + * @param menuItems List of items in the menu. + * @param selectedTabCount Number of selected tabs. + * @param onExitSelectModeClick Invoked when the user clicks on exit select mode button. + * @param onSaveToCollectionsClick Invoked when the user clicks on the save to collection button. + * @param onShareSelectedTabs Invoked when the user clicks on the share button. + */ +@Suppress("LongMethod") +@Composable +private fun MultiSelectBanner( + menuItems: List, + selectedTabCount: Int, + onExitSelectModeClick: () -> Unit, + onSaveToCollectionsClick: () -> Unit, + onShareSelectedTabs: () -> Unit, +) { + var showMenu by remember { mutableStateOf(false) } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(88.dp) + .background(color = WaterfoxTheme.colors.layerAccent), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onExitSelectModeClick) { + Icon( + painter = painterResource(id = R.drawable.ic_close), + contentDescription = stringResource(id = R.string.tab_tray_close_multiselect_content_description), + tint = WaterfoxTheme.colors.iconOnColor, + ) + } + + Text( + text = stringResource(R.string.tab_tray_multi_select_title, selectedTabCount), + modifier = Modifier.testTag(TabsTrayTestTag.selectionCounter), + style = WaterfoxTheme.typography.headline6, + color = WaterfoxTheme.colors.textOnColorPrimary, + ) + + Spacer(modifier = Modifier.weight(1.0f)) + + IconButton( + onClick = onSaveToCollectionsClick, + modifier = Modifier.testTag(TabsTrayTestTag.collectionsButton), + enabled = selectedTabCount > 0, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_tab_collection), + contentDescription = stringResource( + id = R.string.tab_tray_collection_button_multiselect_content_description, + ), + tint = WaterfoxTheme.colors.iconOnColor, + ) + } + + IconButton(onClick = onShareSelectedTabs) { + Icon( + painter = painterResource(id = R.drawable.ic_share), + contentDescription = stringResource( + id = R.string.tab_tray_multiselect_share_content_description, + ), + tint = WaterfoxTheme.colors.iconOnColor, + ) + } + + IconButton(onClick = { showMenu = true }) { + Icon( + painter = painterResource(id = R.drawable.ic_menu), + contentDescription = stringResource(id = R.string.tab_tray_multiselect_menu_content_description), + tint = WaterfoxTheme.colors.iconOnColor, + ) + + ContextualMenu( + menuItems = menuItems, + showMenu = showMenu, + offset = DpOffset(x = 0.dp, y = -ICON_SIZE), + onDismissRequest = { showMenu = false }, + ) + } + } +} + +@LightDarkPreview +@Composable +private fun TabsTrayBannerPreview() { + TabsTrayBannerPreviewRoot( + selectedPage = Page.PrivateTabs, + normalTabCount = 5, + ) +} + +@LightDarkPreview +@Composable +private fun TabsTrayBannerInfinityPreview() { + TabsTrayBannerPreviewRoot( + normalTabCount = 200, + ) +} + +@LightDarkPreview +@Composable +private fun TabsTrayBannerAutoClosePreview() { + TabsTrayBannerPreviewRoot( + shouldShowTabAutoCloseBanner = true, + ) +} + +@LightDarkPreview +@Composable +private fun TabsTrayBannerMultiselectPreview() { + TabsTrayBannerPreviewRoot( + selectMode = TabsTrayState.Mode.Select( + setOf( + TabSessionState( + id = "1", + content = ContentState( + url = "www.mozilla.com", + ), + ), + TabSessionState( + id = "2", + content = ContentState( + url = "www.mozilla.com", + ), + ), + ), + ), + ) +} + +@Composable +private fun TabsTrayBannerPreviewRoot( + selectMode: TabsTrayState.Mode = TabsTrayState.Mode.Normal, + selectedPage: Page = Page.NormalTabs, + normalTabCount: Int = 10, + privateTabCount: Int = 10, + shouldShowTabAutoCloseBanner: Boolean = false, +) { + val normalTabs = generateFakeTabsList(normalTabCount) + val privateTabs = generateFakeTabsList(privateTabCount) + + val tabsTrayStore = TabsTrayStore( + initialState = TabsTrayState( + selectedPage = selectedPage, + mode = selectMode, + normalTabs = normalTabs, + privateTabs = privateTabs, + ), + ) + + WaterfoxTheme { + Box(modifier = Modifier.size(400.dp)) { + TabsTrayBanner( + tabsTrayStore = tabsTrayStore, + isInDebugMode = true, + shouldShowTabAutoCloseBanner = shouldShowTabAutoCloseBanner, + onTabPageIndicatorClicked = { page -> + tabsTrayStore.dispatch(TabsTrayAction.PageSelected(page)) + }, + onSaveToCollectionClick = {}, + onShareSelectedTabsClick = {}, + onShareAllTabsClick = {}, + onTabSettingsClick = {}, + onRecentlyClosedClick = {}, + onAccountSettingsClick = {}, + onDeleteAllTabsClick = {}, + onBookmarkSelectedTabsClick = {}, + onDeleteSelectedTabsClick = {}, + onForceSelectedTabsAsInactiveClick = {}, + onDismissClick = {}, + onTabAutoCloseBannerViewOptionsClick = {}, + onTabAutoCloseBannerDismiss = {}, + onTabAutoCloseBannerShown = {}, + ) + } + } +} + +private object DisabledRippleTheme : RippleTheme { + @Composable + override fun defaultColor() = Color.Unspecified + + @Composable + override fun rippleAlpha(): RippleAlpha = RippleAlpha(0.0f, 0.0f, 0.0f, 0.0f) +} + +private fun generateFakeTabsList(tabCount: Int = 10, isPrivate: Boolean = false): List = + List(tabCount) { index -> + TabSessionState( + id = "tabId$index-$isPrivate", + content = ContentState( + url = "www.mozilla.com", + private = isPrivate, + ), + ) + } diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayController.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayController.kt index e5f924c9e..c22b01d2d 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayController.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayController.kt @@ -6,32 +6,53 @@ package net.waterfox.android.tabstray import androidx.annotation.VisibleForTesting import androidx.navigation.NavController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import mozilla.components.browser.state.action.DebugAction import mozilla.components.browser.state.action.LastAccessAction import mozilla.components.browser.state.selector.findTab import mozilla.components.browser.state.selector.getNormalOrPrivateTabs +import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.SessionState import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.browser.storage.sync.Tab import mozilla.components.concept.base.profiler.Profiler import mozilla.components.concept.engine.mediasession.MediaSession.PlaybackState +import mozilla.components.concept.engine.prompt.ShareData +import mozilla.components.feature.downloads.ui.DownloadCancelDialogFragment import mozilla.components.feature.tabs.TabsUseCases import mozilla.components.lib.state.DelicateAction +import net.waterfox.android.BrowserDirection +import net.waterfox.android.HomeActivity import net.waterfox.android.R import net.waterfox.android.browser.browsingmode.BrowsingMode import net.waterfox.android.browser.browsingmode.BrowsingModeManager +import net.waterfox.android.collections.CollectionsDialog +import net.waterfox.android.collections.show +import net.waterfox.android.components.AppStore +import net.waterfox.android.components.TabCollectionStorage +import net.waterfox.android.components.appstate.AppAction +import net.waterfox.android.components.bookmarks.BookmarksUseCase import net.waterfox.android.ext.DEFAULT_ACTIVE_DAYS +import net.waterfox.android.ext.potentialInactiveTabs import net.waterfox.android.ext.settings import net.waterfox.android.home.HomeFragment +import net.waterfox.android.library.bookmarks.BookmarksSharedViewModel +import net.waterfox.android.tabstray.browser.InactiveTabsController +import net.waterfox.android.tabstray.browser.TabsTrayFabController +import net.waterfox.android.tabstray.ext.getTabSessionState import net.waterfox.android.tabstray.ext.isActiveDownload +import net.waterfox.android.tabstray.ext.isNormalTab +import net.waterfox.android.tabstray.ext.isSelect +import net.waterfox.android.utils.Settings import java.util.concurrent.TimeUnit +import kotlin.coroutines.CoroutineContext -interface TabsTrayController { - - /** - * Called to open a new tab. - */ - fun handleOpeningNewTab(isPrivate: Boolean) +/** + * Controller for handling any actions in the tabs tray. + */ +interface TabsTrayController : SyncedTabsController, InactiveTabsController, TabsTrayFabController { /** * Set the current tray item to the clamped [position]. @@ -49,7 +70,6 @@ interface TabsTrayController { /** * Deletes the [TabSessionState] with the specified [tabId] or calls [DownloadCancelDialogFragment] * if user tries to close the last private tab while private downloads are active. - * Tracks [Event.ClosedExistingTab] in case of deletion. * * @param tabId The id of the [TabSessionState] to be removed from TabsTray. * @param source app feature from which the tab with [tabId] was closed. @@ -58,7 +78,6 @@ interface TabsTrayController { /** * Deletes the [TabSessionState] with the specified [tabId] - * Tracks [Event.ClosedExistingTab] in case of deletion. * * @param tabId The id of the [TabSessionState] to be removed from TabsTray. * @param source app feature from which the tab with [tabId] was closed. @@ -66,18 +85,31 @@ interface TabsTrayController { fun handleDeleteTabWarningAccepted(tabId: String, source: String? = null) /** - * Deletes a list of [tabs]. - * - * @param tabs List of [TabSessionState]s (sessions) to be removed. + * Deletes the current state of selected tabs, offering an undo option. + */ + fun handleDeleteSelectedTabsClicked() + + /** + * Bookmarks the current set of selected tabs. */ - fun handleMultipleTabsDeletion(tabs: Collection) + fun handleBookmarkSelectedTabsClicked() + + /** + * Saves the current set of selected tabs to a collection. + */ + fun handleAddSelectedTabsToCollectionClicked() + + /** + * Shares the current set of selected tabs. + */ + fun handleShareSelectedTabsClicked() /** * Moves [tabId] next to before/after [targetId] * - * @param tabId The tabs to be moved - * @param targetId The id of the tab that the [tab] will be placed next to - * @param placeAfter Place [tabs] before or after the target + * @param tabId The tab to be moved. + * @param targetId The id of the tab that the moved tab will be placed next to. + * @param placeAfter [Boolean] indicating whether to place the tab before or after the target. */ fun handleTabsMove(tabId: String, targetId: String?, placeAfter: Boolean) @@ -87,41 +119,126 @@ interface TabsTrayController { fun handleNavigateToRecentlyClosed() /** - * Set the list of [tabs] into the inactive state. + * Sets the current state of selected tabs into the inactive state. * * ⚠️ DO NOT USE THIS OUTSIDE OF DEBUGGING/TESTING. * - * @param tabs List of [TabSessionState]s to be removed. + * @param numDays The number of days to mark a tab's last access date. */ - fun forceTabsAsInactive( - tabs: Collection, - numOfDays: Long = DEFAULT_ACTIVE_DAYS + 1 - ) + fun handleForceSelectedTabsAsInactiveClicked(numDays: Long = DEFAULT_ACTIVE_DAYS + 1) + /** * Handles when a tab item is click either to play/pause. */ fun handleMediaClicked(tab: SessionState) + + /** + * Adds the provided tab to the current selection of tabs. + * + * @param tab [TabSessionState] that was long clicked. + */ + fun handleTabLongClick(tab: TabSessionState): Boolean + + /** + * Adds the provided tab to the current selection of tabs. + * + * @param tab [TabSessionState] to be selected. + * @param source App feature from which the tab was selected. + */ + fun handleTabSelected( + tab: TabSessionState, + source: String?, + ) + + /** + * Removes the provided tab from the current selection of tabs. + * + * @param tab [TabSessionState] to be unselected. + */ + fun handleTabUnselected(tab: TabSessionState) + + /** + * Exits multi select mode when the back button was pressed. + * + * @return true if the button press was consumed. + */ + fun handleBackPressed(): Boolean } -@Suppress("TooManyFunctions") +/** + * Default implementation of [TabsTrayController]. + * + * @param activity [HomeActivity] used to perform top-level app actions. + * @param appStore [AppStore] used to dispatch any [AppAction]. + * @param tabsTrayStore [TabsTrayStore] used to read/update the [TabsTrayState]. + * @param browserStore [BrowserStore] used to read/update the current [BrowserState]. + * @param settings [Settings] used to update any user preferences. + * @param browsingModeManager [BrowsingModeManager] used to read/update the current [BrowsingMode]. + * @param navController [NavController] used to navigate away from the tabs tray. + * @param navigateToHomeAndDeleteSession Lambda used to return to the Homescreen and delete the current session. + * @param profiler [Profiler] used to add profiler markers. + * @param navigationInteractor [NavigationInteractor] used to perform navigation actions with side effects. + * @param tabsUseCases Use case wrapper for interacting with tabs. + * @param bookmarksUseCase Use case wrapper for interacting with bookmarks. + * @param ioDispatcher [CoroutineContext] used to handle saving tabs as bookmarks. + * @param collectionStorage Storage layer for interacting with collections. + * @param selectTabPosition Lambda used to scroll the tabs tray to the desired position. + * @param dismissTray Lambda used to dismiss/minimize the tabs tray. + * @param showUndoSnackbarForTab Lambda used to display an UNDO Snackbar. + * @property showCancelledDownloadWarning Lambda used to display a cancelled download warning. + * @param showBookmarkSnackbar Lambda used to display a snackbar upon saving tabs as bookmarks. + * @param showCollectionSnackbar Lambda used to display a snackbar upon successfully saving tabs + * to a collection. + * @param bookmarksSharedViewModel [BookmarksSharedViewModel] used to get currently selected bookmark root. + */ +@Suppress("TooManyFunctions", "LongParameterList") class DefaultTabsTrayController( - private val trayStore: TabsTrayStore, + private val activity: HomeActivity, + private val appStore: AppStore, + private val tabsTrayStore: TabsTrayStore, private val browserStore: BrowserStore, + private val settings: Settings, private val browsingModeManager: BrowsingModeManager, private val navController: NavController, private val navigateToHomeAndDeleteSession: (String) -> Unit, private val profiler: Profiler?, private val navigationInteractor: NavigationInteractor, private val tabsUseCases: TabsUseCases, + private val bookmarksUseCase: BookmarksUseCase, + private val ioDispatcher: CoroutineContext, + private val collectionStorage: TabCollectionStorage, private val selectTabPosition: (Int, Boolean) -> Unit, private val dismissTray: () -> Unit, private val showUndoSnackbarForTab: (Boolean) -> Unit, - @get:VisibleForTesting internal val showCancelledDownloadWarning: (downloadCount: Int, tabId: String?, source: String?) -> Unit, - + private val showBookmarkSnackbar: (tabSize: Int) -> Unit, + private val showCollectionSnackbar: ( + tabSize: Int, + isNewCollection: Boolean, + ) -> Unit, + private val bookmarksSharedViewModel: BookmarksSharedViewModel, ) : TabsTrayController { - override fun handleOpeningNewTab(isPrivate: Boolean) { + override fun handleNormalTabsFabClick() { + openNewTab(isPrivate = false) + } + + override fun handlePrivateTabsFabClick() { + openNewTab(isPrivate = true) + } + + override fun handleSyncedTabsFabClick() { + if (!tabsTrayStore.state.syncing) { + tabsTrayStore.dispatch(TabsTrayAction.SyncNow) + } + } + + /** + * Opens a new tab. + * + * @param isPrivate [Boolean] indicating whether the new tab is private. + */ + private fun openNewTab(isPrivate: Boolean) { val startTime = profiler?.getProfilerTime() browsingModeManager.mode = BrowsingMode.fromBoolean(isPrivate) @@ -154,13 +271,14 @@ class DefaultTabsTrayController( navigationInteractor.onTabTrayDismissed() profiler?.addMarker( "DefaultTabTrayController.onNewTabTapped", - startTime + startTime, ) } override fun handleTrayScrollingToPosition(position: Int, smoothScroll: Boolean) { + val page = Page.positionToPage(position) selectTabPosition(position, smoothScroll) - trayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(position))) + tabsTrayStore.dispatch(TabsTrayAction.PageSelected(page)) } /** @@ -196,7 +314,8 @@ class DefaultTabsTrayController( tab?.let { val isLastTab = browserStore.state.getNormalOrPrivateTabs(it.content.private).size == 1 - if (!isLastTab) { + val isCurrentTab = browserStore.state.selectedTabId.equals(tabId) + if (!isLastTab || !isCurrentTab) { tabsUseCases.removeTab(tabId) showUndoSnackbarForTab(it.content.private) } else { @@ -211,21 +330,29 @@ class DefaultTabsTrayController( } } } + + tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) + } + + override fun handleDeleteSelectedTabsClicked() { + val tabs = tabsTrayStore.state.mode.selectedTabs + + deleteMultipleTabs(tabs) + + tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) } /** - * Deletes a list of [tabs] offering an undo option. - * - * @param tabs List of [TabSessionState]s (sessions) to be removed. - * This method has no effect for tabs that do not exist. + * Helper function to delete multiple tabs and offer an undo option. */ - override fun handleMultipleTabsDeletion(tabs: Collection) { + @VisibleForTesting + internal fun deleteMultipleTabs(tabs: Collection) { val isPrivate = tabs.any { it.content.private } // If user closes all the tabs from selected tabs page dismiss tray and navigate home. if (tabs.size == browserStore.state.getNormalOrPrivateTabs(isPrivate).size) { dismissTabsTrayAndNavigateHome( - if (isPrivate) HomeFragment.ALL_PRIVATE_TABS else HomeFragment.ALL_NORMAL_TABS + if (isPrivate) HomeFragment.ALL_PRIVATE_TABS else HomeFragment.ALL_NORMAL_TABS, ) } else { tabs.map { it.id }.let { @@ -235,17 +362,10 @@ class DefaultTabsTrayController( showUndoSnackbarForTab(isPrivate) } - /** - * Moves [tabId] next to before/after [targetId] - * - * @param tabId The tabs to be moved - * @param targetId The id of the tab that the [tab] will be placed next to - * @param placeAfter Place [tabs] before or after the target - */ override fun handleTabsMove( tabId: String, targetId: String?, - placeAfter: Boolean + placeAfter: Boolean, ) { if (targetId != null && tabId != targetId) { tabsUseCases.moveTabs(listOf(tabId), targetId, placeAfter) @@ -262,20 +382,83 @@ class DefaultTabsTrayController( } /** - * Marks all the [tabs] with the [TabSessionState.lastAccess] to 15 days; enough time to + * Marks all selected tabs with the [TabSessionState.lastAccess] to 15 days or [numDays]; enough time to * have a tab considered as inactive. * * ⚠️ DO NOT USE THIS OUTSIDE OF DEBUGGING/TESTING. + * + * @param numDays The number of days to mark a tab's last access date. */ @OptIn(DelicateAction::class) - override fun forceTabsAsInactive(tabs: Collection, numOfDays: Long) { + override fun handleForceSelectedTabsAsInactiveClicked(numDays: Long) { + val tabs = tabsTrayStore.state.mode.selectedTabs + val currentTabId = browserStore.state.selectedTabId + tabs + .filterNot { it.id == currentTabId } + .forEach { tab -> + val daysSince = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(numDays) + browserStore.apply { + dispatch(LastAccessAction.UpdateLastAccessAction(tab.id, daysSince)) + dispatch(DebugAction.UpdateCreatedAtAction(tab.id, daysSince)) + } + } + + tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) + } + + override fun handleBookmarkSelectedTabsClicked() { + val tabs = tabsTrayStore.state.mode.selectedTabs + tabs.forEach { tab -> - val daysSince = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(numOfDays) - browserStore.apply { - dispatch(LastAccessAction.UpdateLastAccessAction(tab.id, daysSince)) - dispatch(DebugAction.UpdateCreatedAtAction(tab.id, daysSince)) + // We don't combine the context with lifecycleScope so that our jobs are not cancelled + // if we leave the fragment, i.e. we still want the bookmarks to be added if the + // tabs tray closes before the job is done. + CoroutineScope(ioDispatcher).launch { + bookmarksUseCase.addBookmark( + tab.content.url, + tab.content.title, + parentGuid = bookmarksSharedViewModel.selectedFolder?.guid, + ) } } + + showBookmarkSnackbar(tabs.size) + + tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) + } + + override fun handleAddSelectedTabsToCollectionClicked() { + val tabs = tabsTrayStore.state.mode.selectedTabs + + tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) + + showCollectionsDialog(tabs) + } + + @VisibleForTesting + internal fun showCollectionsDialog(tabs: Collection) { + CollectionsDialog( + storage = collectionStorage, + sessionList = browserStore.getTabSessionState(tabs), + onPositiveButtonClick = { id, isNewCollection -> + id?.apply { + showCollectionSnackbar(tabs.size, isNewCollection) + } + }, + onNegativeButtonClick = {}, + ).show(activity) + } + + override fun handleShareSelectedTabsClicked() { + val tabs = tabsTrayStore.state.mode.selectedTabs + + val data = tabs.map { + ShareData(url = it.content.url, title = it.content.title) + } + val directions = TabsTrayFragmentDirections.actionGlobalShareFragment( + data = data.toTypedArray(), + ) + navController.navigate(directions) } @VisibleForTesting @@ -294,8 +477,91 @@ class DefaultTabsTrayController( tab.mediaSessionState?.controller?.play() } else -> throw AssertionError( - "Play/Pause button clicked without play/pause state." + "Play/Pause button clicked without play/pause state.", ) } } + + override fun handleSyncedTabClicked(tab: Tab) { + dismissTray() + activity.openToBrowserAndLoad( + searchTermOrURL = tab.active().url, + newTab = true, + from = BrowserDirection.FromTabsTray, + ) + } + + override fun handleTabLongClick(tab: TabSessionState): Boolean { + return if (tab.isNormalTab() && tabsTrayStore.state.mode.selectedTabs.isEmpty()) { + tabsTrayStore.dispatch(TabsTrayAction.AddSelectTab(tab)) + true + } else { + false + } + } + + override fun handleTabSelected(tab: TabSessionState, source: String?) { + val selected = tabsTrayStore.state.mode.selectedTabs + when { + selected.isEmpty() && tabsTrayStore.state.mode.isSelect().not() -> { + tabsUseCases.selectTab(tab.id) + browsingModeManager.mode = BrowsingMode.fromBoolean(tab.content.private) + handleNavigateToBrowser() + } + tab.id in selected.map { it.id } -> handleTabUnselected(tab) + source != TrayPagerAdapter.INACTIVE_TABS_FEATURE_NAME -> { + tabsTrayStore.dispatch(TabsTrayAction.AddSelectTab(tab)) + } + } + } + + override fun handleTabUnselected(tab: TabSessionState) { + tabsTrayStore.dispatch(TabsTrayAction.RemoveSelectTab(tab)) + } + + override fun handleBackPressed(): Boolean { + if (tabsTrayStore.state.mode is TabsTrayState.Mode.Select) { + tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) + return true + } + return false + } + + override fun handleInactiveTabClicked(tab: TabSessionState) { + handleTabSelected(tab, TrayPagerAdapter.INACTIVE_TABS_FEATURE_NAME) + } + + override fun handleCloseInactiveTabClicked(tab: TabSessionState) { + handleTabDeletion(tab.id, TrayPagerAdapter.INACTIVE_TABS_FEATURE_NAME) + } + + override fun handleInactiveTabsHeaderClicked(expanded: Boolean) { + appStore.dispatch(AppAction.UpdateInactiveExpanded(expanded)) + } + + override fun handleInactiveTabsAutoCloseDialogDismiss() { + markDialogAsShown() + } + + override fun handleEnableInactiveTabsAutoCloseClicked() { + markDialogAsShown() + settings.closeTabsAfterOneMonth = true + settings.closeTabsAfterOneWeek = false + settings.closeTabsAfterOneDay = false + settings.manuallyCloseTabs = false + } + + override fun handleDeleteAllInactiveTabsClicked() { + browserStore.state.potentialInactiveTabs.map { it.id }.let { + tabsUseCases.removeTabs(it) + } + showUndoSnackbarForTab(false) + } + + /** + * Marks the inactive tabs auto close dialog as shown and to not be displayed again. + */ + private fun markDialogAsShown() { + settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true + } } diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayDialog.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayDialog.kt index 15070a5aa..de25158c2 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayDialog.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayDialog.kt @@ -6,7 +6,6 @@ package net.waterfox.android.tabstray import android.app.Dialog import android.content.Context -import net.waterfox.android.tabstray.browser.BrowserTrayInteractor /** * Default tabs tray dialog implementation for overriding the default on back pressed. @@ -14,10 +13,11 @@ import net.waterfox.android.tabstray.browser.BrowserTrayInteractor class TabsTrayDialog( context: Context, theme: Int, - private val interactor: () -> BrowserTrayInteractor + private val interactor: () -> TabsTrayInteractor, ) : Dialog(context, theme) { + @Deprecated("Deprecated in Java") override fun onBackPressed() { - if (interactor.invoke().onBackPressed()) { + if (interactor().onBackPressed()) { return } diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayFab.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayFab.kt new file mode 100644 index 000000000..1e7ae7774 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayFab.kt @@ -0,0 +1,131 @@ +/* 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 net.waterfox.android.tabstray + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import mozilla.components.lib.state.ext.observeAsComposableState +import net.waterfox.android.R +import net.waterfox.android.compose.annotation.LightDarkPreview +import net.waterfox.android.compose.button.FloatingActionButton +import net.waterfox.android.theme.WaterfoxTheme + +/** + * Floating action button for tabs tray. + * + * @param tabsTrayStore [TabsTrayStore] used to listen for changes to [TabsTrayState]. + * @param isSignedIn Used to know when to show the SYNC FAB when [Page.SyncedTabs] is displayed. + * @param onNormalTabsFabClicked Invoked when the fab is clicked in [Page.NormalTabs]. + * @param onPrivateTabsFabClicked Invoked when the fab is clicked in [Page.PrivateTabs]. + * @param onSyncedTabsFabClicked Invoked when the fab is clicked in [Page.SyncedTabs]. + */ +@Composable +fun TabsTrayFab( + tabsTrayStore: TabsTrayStore, + isSignedIn: Boolean, + onNormalTabsFabClicked: () -> Unit, + onPrivateTabsFabClicked: () -> Unit, + onSyncedTabsFabClicked: () -> Unit, +) { + val currentPage: Page = tabsTrayStore.observeAsComposableState { state -> + state.selectedPage + }.value ?: Page.NormalTabs + val isSyncing: Boolean = tabsTrayStore.observeAsComposableState { state -> + state.syncing + }.value ?: false + val isInNormalMode: Boolean = tabsTrayStore.observeAsComposableState { state -> + state.mode == TabsTrayState.Mode.Normal + }.value ?: false + + val icon: Painter + val contentDescription: String + val label: String? + val onClick: () -> Unit + + when (currentPage) { + Page.NormalTabs -> { + icon = painterResource(id = R.drawable.ic_new) + contentDescription = stringResource(id = R.string.add_tab) + label = null + onClick = onNormalTabsFabClicked + } + + Page.SyncedTabs -> { + icon = painterResource(id = R.drawable.ic_fab_sync) + contentDescription = stringResource(id = R.string.resync_button_content_description) + label = if (isSyncing) { + stringResource(id = R.string.sync_syncing_in_progress) + } else { + stringResource(id = R.string.tab_drawer_fab_sync) + }.uppercase() + onClick = onSyncedTabsFabClicked + } + + Page.PrivateTabs -> { + icon = painterResource(id = R.drawable.ic_new) + contentDescription = stringResource(id = R.string.add_private_tab) + label = stringResource(id = R.string.tab_drawer_fab_content).uppercase() + onClick = onPrivateTabsFabClicked + } + } + + if (isInNormalMode && !(currentPage == Page.SyncedTabs && !isSignedIn)) { + FloatingActionButton( + icon = icon, + modifier = Modifier + .padding(bottom = 16.dp, end = 16.dp) + .testTag(TabsTrayTestTag.fab), + contentDescription = contentDescription, + label = label, + onClick = onClick, + ) + } +} + +@LightDarkPreview +@Composable +private fun TabsTraySyncFabPreview() { + val store = TabsTrayStore( + initialState = TabsTrayState( + selectedPage = Page.SyncedTabs, + syncing = true, + ), + ) + + WaterfoxTheme { + TabsTrayFab( + tabsTrayStore = store, + isSignedIn = true, + onNormalTabsFabClicked = {}, + onPrivateTabsFabClicked = {}, + onSyncedTabsFabClicked = {}, + ) + } +} + +@LightDarkPreview +@Composable +private fun TabsTrayPrivateFabPreview() { + val store = TabsTrayStore( + initialState = TabsTrayState( + selectedPage = Page.PrivateTabs, + ), + ) + WaterfoxTheme { + TabsTrayFab( + tabsTrayStore = store, + isSignedIn = true, + onNormalTabsFabClicked = {}, + onPrivateTabsFabClicked = {}, + onSyncedTabsFabClicked = {}, + ) + } +} diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayFragment.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayFragment.kt index 1c0522e6d..57c34d5e8 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayFragment.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayFragment.kt @@ -4,6 +4,7 @@ package net.waterfox.android.tabstray +import android.app.Dialog import android.content.Context import android.content.res.Configuration import android.os.Build @@ -28,26 +29,43 @@ import mozilla.appservices.places.BookmarkRoot import mozilla.components.browser.state.selector.normalTabs import mozilla.components.browser.state.selector.privateTabs import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.base.crash.Breadcrumb import mozilla.components.feature.downloads.ui.DownloadCancelDialogFragment import mozilla.components.feature.tabs.tabstray.TabsFeature import mozilla.components.support.base.feature.ViewBoundFeatureWrapper +import net.waterfox.android.Config import net.waterfox.android.HomeActivity import net.waterfox.android.NavGraphDirections import net.waterfox.android.R import net.waterfox.android.components.StoreProvider import net.waterfox.android.components.WaterfoxSnackbar -import net.waterfox.android.databinding.* +import net.waterfox.android.databinding.ComponentTabstray2Binding +import net.waterfox.android.databinding.ComponentTabstray3Binding +import net.waterfox.android.databinding.ComponentTabstray3FabBinding +import net.waterfox.android.databinding.ComponentTabstrayFabBinding +import net.waterfox.android.databinding.FragmentTabTrayDialogBinding +import net.waterfox.android.databinding.TabsTrayTabCounter2Binding +import net.waterfox.android.databinding.TabstrayMultiselectItemsBinding import net.waterfox.android.ext.components import net.waterfox.android.ext.requireComponents import net.waterfox.android.ext.runIfFragmentIsAttached import net.waterfox.android.ext.settings import net.waterfox.android.home.HomeScreenViewModel +import net.waterfox.android.library.bookmarks.BookmarksSharedViewModel import net.waterfox.android.share.ShareFragment -import net.waterfox.android.tabstray.browser.* +import net.waterfox.android.tabstray.browser.SelectionBannerBinding import net.waterfox.android.tabstray.browser.SelectionBannerBinding.VisibilityModifier -import net.waterfox.android.tabstray.ext.* +import net.waterfox.android.tabstray.browser.SelectionHandleBinding +import net.waterfox.android.tabstray.browser.TabSorter +import net.waterfox.android.tabstray.ext.anchorWithAction +import net.waterfox.android.tabstray.ext.bookmarkMessage +import net.waterfox.android.tabstray.ext.collectionMessage +import net.waterfox.android.tabstray.ext.make +import net.waterfox.android.tabstray.ext.showWithTheme import net.waterfox.android.tabstray.syncedtabs.SyncedTabsIntegration +import net.waterfox.android.theme.Theme import net.waterfox.android.theme.ThemeManager +import net.waterfox.android.theme.WaterfoxTheme import net.waterfox.android.utils.allowUndo import kotlin.math.max @@ -56,18 +74,18 @@ import kotlin.math.max */ enum class TabsTrayAccessPoint { None, - HomeRecentSyncedTab + HomeRecentSyncedTab, } @Suppress("TooManyFunctions", "LargeClass") class TabsTrayFragment : AppCompatDialogFragment() { @VisibleForTesting internal lateinit var tabsTrayStore: TabsTrayStore - private lateinit var browserTrayInteractor: BrowserTrayInteractor + private lateinit var tabsTrayDialog: TabsTrayDialog private lateinit var tabsTrayInteractor: TabsTrayInteractor private lateinit var tabsTrayController: DefaultTabsTrayController - private lateinit var inactiveTabsInteractor: DefaultInactiveTabsInteractor private lateinit var navigationInteractor: DefaultNavigationInteractor + @VisibleForTesting internal lateinit var trayBehaviorManager: TabSheetBehaviorManager private val tabLayoutMediator = ViewBoundFeatureWrapper() @@ -80,46 +98,38 @@ class TabsTrayFragment : AppCompatDialogFragment() { private val tabsFeature = ViewBoundFeatureWrapper() private val tabsTrayInactiveTabsOnboardingBinding = ViewBoundFeatureWrapper() private val syncedTabsIntegration = ViewBoundFeatureWrapper() + private val bookmarksSharedViewModel: BookmarksSharedViewModel by activityViewModels() - @VisibleForTesting @Suppress("VariableNaming") + @VisibleForTesting + @Suppress("VariableNaming") internal var _tabsTrayBinding: ComponentTabstray2Binding? = null private val tabsTrayBinding get() = _tabsTrayBinding!! - @VisibleForTesting @Suppress("VariableNaming") + + @VisibleForTesting + @Suppress("VariableNaming") internal var _tabsTrayDialogBinding: FragmentTabTrayDialogBinding? = null private val tabsTrayDialogBinding get() = _tabsTrayDialogBinding!! - @VisibleForTesting @Suppress("VariableNaming") + + @VisibleForTesting + @Suppress("VariableNaming") internal var _fabButtonBinding: ComponentTabstrayFabBinding? = null private val fabButtonBinding get() = _fabButtonBinding!! + @VisibleForTesting + @Suppress("VariableNaming") + internal var _tabsTrayComposeBinding: ComponentTabstray3Binding? = null + private val tabsTrayComposeBinding get() = _tabsTrayComposeBinding!! + + @Suppress("VariableNaming") + internal var _fabButtonComposeBinding: ComponentTabstray3FabBinding? = null + private val fabButtonComposeBinding get() = _fabButtonComposeBinding!! + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setStyle(STYLE_NO_TITLE, R.style.TabTrayDialogStyle) } - override fun onCreateDialog(savedInstanceState: Bundle?) = - TabsTrayDialog(requireContext(), theme) { browserTrayInteractor } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _tabsTrayDialogBinding = FragmentTabTrayDialogBinding.inflate( - inflater, - container, - false - ) - _tabsTrayBinding = ComponentTabstray2Binding.inflate( - inflater, - tabsTrayDialogBinding.root, - true - ) - _fabButtonBinding = ComponentTabstrayFabBinding.inflate( - LayoutInflater.from(tabsTrayDialogBinding.root.context), - tabsTrayDialogBinding.root, - true - ) - + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val args by navArgs() val initialMode = if (args.enterMultiselect) { TabsTrayState.Mode.Select(emptySet()) @@ -127,6 +137,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { TabsTrayState.Mode.Normal } val initialPage = args.page + val activity = activity as HomeActivity tabsTrayStore = StoreProvider.get(this) { TabsTrayStore( @@ -134,219 +145,366 @@ class TabsTrayFragment : AppCompatDialogFragment() { selectedPage = initialPage, mode = initialMode, ), - middlewares = listOf( - TabsTrayMiddleware() - ) ) } - return tabsTrayDialogBinding.root - } - - override fun onStart() { - super.onStart() - findPreviousDialogFragment()?.let { dialog -> - dialog.onAcceptClicked = ::onCancelDownloadWarningAccepted - } - } - - override fun onDestroyView() { - super.onDestroyView() - _tabsTrayBinding = null - _tabsTrayDialogBinding = null - _fabButtonBinding = null - } - - @Suppress("LongMethod") - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - val activity = activity as HomeActivity - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { - fabButtonBinding.newTabButton.accessibilityTraversalAfter = - tabsTrayBinding.tabLayout.id - } - navigationInteractor = DefaultNavigationInteractor( - context = requireContext(), - activity = activity, - tabsTrayStore = tabsTrayStore, browserStore = requireComponents.core.store, navController = findNavController(), dismissTabTray = ::dismissTabsTray, dismissTabTrayAndNavigateHome = ::dismissTabsTrayAndNavigateHome, - bookmarksUseCase = requireComponents.useCases.bookmarksUseCases, - collectionStorage = requireComponents.core.tabCollectionStorage, - showCollectionSnackbar = ::showCollectionSnackbar, - showBookmarkSnackbar = ::showBookmarkSnackbar, showCancelledDownloadWarning = ::showCancelledDownloadWarning, accountManager = requireComponents.backgroundServices.accountManager, - ioDispatcher = Dispatchers.IO ) tabsTrayController = DefaultTabsTrayController( - trayStore = tabsTrayStore, + activity = activity, + appStore = requireComponents.appStore, + tabsTrayStore = tabsTrayStore, browserStore = requireComponents.core.store, + settings = requireContext().settings(), browsingModeManager = activity.browsingModeManager, navController = findNavController(), navigateToHomeAndDeleteSession = ::navigateToHomeAndDeleteSession, navigationInteractor = navigationInteractor, profiler = requireComponents.core.engine.profiler, tabsUseCases = requireComponents.useCases.tabsUseCases, + bookmarksUseCase = requireComponents.useCases.bookmarksUseCases, + ioDispatcher = Dispatchers.IO, + collectionStorage = requireComponents.core.tabCollectionStorage, selectTabPosition = ::selectTabPosition, dismissTray = ::dismissTabsTray, showUndoSnackbarForTab = ::showUndoSnackbarForTab, - showCancelledDownloadWarning = ::showCancelledDownloadWarning + showCancelledDownloadWarning = ::showCancelledDownloadWarning, + showCollectionSnackbar = ::showCollectionSnackbar, + showBookmarkSnackbar = ::showBookmarkSnackbar, + bookmarksSharedViewModel = bookmarksSharedViewModel, ) - tabsTrayInteractor = DefaultTabsTrayInteractor(tabsTrayController) - - browserTrayInteractor = DefaultBrowserTrayInteractor( - tabsTrayStore, - tabsTrayInteractor, - tabsTrayController, - requireComponents.useCases.tabsUseCases.selectTab, + tabsTrayInteractor = DefaultTabsTrayInteractor( + controller = tabsTrayController, ) + tabsTrayDialog = TabsTrayDialog(requireContext(), theme) { tabsTrayInteractor } + return tabsTrayDialog + } - inactiveTabsInteractor = DefaultInactiveTabsInteractor( - controller = DefaultInactiveTabsController( - appStore = requireComponents.appStore, - settings = requireContext().settings(), - browserStore = requireComponents.core.store, - tabsUseCases = requireComponents.useCases.tabsUseCases, - showUndoSnackbar = ::showUndoSnackbarForTab, - ), - browserInteractor = browserTrayInteractor, - ) + override fun onPause() { + super.onPause() + dialog?.window?.setWindowAnimations(R.style.DialogFragmentRestoreAnimation) + } - setupMenu(navigationInteractor) - setupPager( - context = view.context, - lifecycleOwner = viewLifecycleOwner, - store = tabsTrayStore, - trayInteractor = tabsTrayInteractor, - browserInteractor = browserTrayInteractor, - navigationInteractor = navigationInteractor, - inactiveTabsInteractor = inactiveTabsInteractor, + @Suppress("LongMethod") + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + _tabsTrayDialogBinding = FragmentTabTrayDialogBinding.inflate( + inflater, + container, + false, ) - setupBackgroundDismissalListener { - dismissAllowingStateLoss() + if (requireContext().settings().enableTabsTrayToCompose) { + _tabsTrayComposeBinding = ComponentTabstray3Binding.inflate( + inflater, + tabsTrayDialogBinding.root, + true, + ) + + _fabButtonComposeBinding = ComponentTabstray3FabBinding.inflate( + inflater, + tabsTrayDialogBinding.root, + true, + ) + + tabsTrayComposeBinding.root.setContent { + WaterfoxTheme(theme = Theme.getTheme(allowPrivateTheme = false)) { + TabsTray( + appStore = requireComponents.appStore, + browserStore = requireComponents.core.store, + tabsTrayStore = tabsTrayStore, + storage = requireComponents.core.thumbnailStorage, + displayTabsInGrid = requireContext().settings().gridTabView, + isInDebugMode = Config.channel.isDebug || + requireComponents.settings.showSecretDebugMenuThisSession, + shouldShowTabAutoCloseBanner = requireContext().settings().shouldShowAutoCloseTabsBanner && + requireContext().settings().canShowCfr, + shouldShowInactiveTabsAutoCloseDialog = + requireContext().settings()::shouldShowInactiveTabsAutoCloseDialog, + onTabPageClick = { page -> + tabsTrayInteractor.onTrayPositionSelected(page.ordinal, false) + }, + onTabClose = { tab -> + tabsTrayInteractor.onTabClosed(tab, TABS_TRAY_FEATURE_NAME) + }, + onTabMediaClick = tabsTrayInteractor::onMediaClicked, + onTabClick = { tab -> + tabsTrayInteractor.onTabSelected(tab, TABS_TRAY_FEATURE_NAME) + }, + onTabLongClick = tabsTrayInteractor::onTabLongClicked, + onInactiveTabsHeaderClick = tabsTrayInteractor::onInactiveTabsHeaderClicked, + onDeleteAllInactiveTabsClick = tabsTrayInteractor::onDeleteAllInactiveTabsClicked, + onInactiveTabsAutoCloseDialogShown = {}, + onInactiveTabAutoCloseDialogCloseButtonClick = + tabsTrayInteractor::onAutoCloseDialogCloseButtonClicked, + onEnableInactiveTabAutoCloseClick = { + tabsTrayInteractor.onEnableAutoCloseClicked() + showInactiveTabsAutoCloseConfirmationSnackbar() + }, + onInactiveTabClick = tabsTrayInteractor::onInactiveTabClicked, + onInactiveTabClose = tabsTrayInteractor::onInactiveTabClosed, + onSyncedTabClick = tabsTrayInteractor::onSyncedTabClicked, + onSaveToCollectionClick = tabsTrayInteractor::onAddSelectedTabsToCollectionClicked, + onShareSelectedTabsClick = tabsTrayInteractor::onShareSelectedTabs, + onShareAllTabsClick = { + navigationInteractor.onShareTabsOfTypeClicked( + private = tabsTrayStore.state.selectedPage == Page.PrivateTabs, + ) + }, + onTabSettingsClick = navigationInteractor::onTabSettingsClicked, + onRecentlyClosedClick = navigationInteractor::onOpenRecentlyClosedClicked, + onAccountSettingsClick = navigationInteractor::onAccountSettingsClicked, + onDeleteAllTabsClick = { + navigationInteractor.onCloseAllTabsClicked( + private = tabsTrayStore.state.selectedPage == Page.PrivateTabs, + ) + }, + onDeleteSelectedTabsClick = tabsTrayInteractor::onDeleteSelectedTabsClicked, + onBookmarkSelectedTabsClick = tabsTrayInteractor::onBookmarkSelectedTabsClicked, + onForceSelectedTabsAsInactiveClick = tabsTrayInteractor::onForceSelectedTabsAsInactiveClicked, + onTabsTrayDismiss = ::onTabsTrayDismissed, + onTabAutoCloseBannerViewOptionsClick = { + navigationInteractor.onTabSettingsClicked() + requireContext().settings().shouldShowAutoCloseTabsBanner = false + }, + onTabAutoCloseBannerDismiss = { + requireContext().settings().shouldShowAutoCloseTabsBanner = false + }, + onTabAutoCloseBannerShown = { + requireContext().settings().lastCfrShownTimeInMillis = System.currentTimeMillis() + }, + onMove = tabsTrayInteractor::onTabsMove, + ) + } + } + + fabButtonComposeBinding.root.setContent { + WaterfoxTheme(theme = Theme.getTheme(allowPrivateTheme = false)) { + TabsTrayFab( + tabsTrayStore = tabsTrayStore, + isSignedIn = requireContext().settings().signedInFxaAccount, + onNormalTabsFabClicked = tabsTrayInteractor::onNormalTabsFabClicked, + onPrivateTabsFabClicked = tabsTrayInteractor::onPrivateTabsFabClicked, + onSyncedTabsFabClicked = tabsTrayInteractor::onSyncedTabsFabClicked, + ) + } + } + } else { + _tabsTrayBinding = ComponentTabstray2Binding.inflate( + inflater, + tabsTrayDialogBinding.root, + true, + ) + _fabButtonBinding = ComponentTabstrayFabBinding.inflate( + inflater, + tabsTrayDialogBinding.root, + true, + ) + } + + return tabsTrayDialogBinding.root + } + + override fun onStart() { + super.onStart() + findPreviousDialogFragment()?.let { dialog -> + dialog.onAcceptClicked = ::onCancelDownloadWarningAccepted + } + } + + override fun onDestroyView() { + super.onDestroyView() + _tabsTrayBinding = null + _tabsTrayDialogBinding = null + _fabButtonBinding = null + _tabsTrayComposeBinding = null + _fabButtonComposeBinding = null + } + + @Suppress("LongMethod") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val rootView = if (requireContext().settings().enableTabsTrayToCompose) { + tabsTrayComposeBinding.root + } else { + tabsTrayBinding.tabWrapper + } + + val newTabFab = if (requireContext().settings().enableTabsTrayToCompose) { + fabButtonComposeBinding.root + } else { + fabButtonBinding.newTabButton + } + + val behavior = BottomSheetBehavior.from(rootView).apply { + addBottomSheetCallback( + TraySheetBehaviorCallback( + this, + navigationInteractor, + tabsTrayDialog, + newTabFab, + ), + ) + skipCollapsed = true } trayBehaviorManager = TabSheetBehaviorManager( - behavior = BottomSheetBehavior.from(tabsTrayBinding.tabWrapper), + behavior = behavior, orientation = resources.configuration.orientation, maxNumberOfTabs = max( requireContext().components.core.store.state.normalTabs.size, - requireContext().components.core.store.state.privateTabs.size + requireContext().components.core.store.state.privateTabs.size, ), numberForExpandingTray = if (requireContext().settings().gridTabView) { EXPAND_AT_GRID_SIZE } else { EXPAND_AT_LIST_SIZE }, - navigationInteractor = navigationInteractor, - displayMetrics = requireContext().resources.displayMetrics + displayMetrics = requireContext().resources.displayMetrics, ) - tabsFeature.set( - feature = TabsFeature( - tabsTray = TabSorter( - requireContext().settings(), - tabsTrayStore - ), - store = requireContext().components.core.store, - ), - owner = this, - view = view - ) + setupBackgroundDismissalListener { + onTabsTrayDismissed() + } + + if (!requireContext().settings().enableTabsTrayToCompose) { + val activity = activity as HomeActivity + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + fabButtonBinding.newTabButton.accessibilityTraversalAfter = + tabsTrayBinding.tabLayout.id + } - tabsTrayCtaBinding.set( - feature = TabsTrayInfoBannerBinding( + setupMenu(navigationInteractor) + setupPager( context = view.context, - store = requireComponents.core.store, - infoBannerView = tabsTrayBinding.infoBanner, - settings = requireComponents.settings, - navigationInteractor = navigationInteractor - ), - owner = this, - view = view - ) + lifecycleOwner = viewLifecycleOwner, + store = tabsTrayStore, + trayInteractor = tabsTrayInteractor, + ) - tabLayoutMediator.set( - feature = TabLayoutMediator( - tabLayout = tabsTrayBinding.tabLayout, - tabPager = tabsTrayBinding.tabsTray, - interactor = tabsTrayInteractor, - browsingModeManager = activity.browsingModeManager, - tabsTrayStore = tabsTrayStore, - ), - owner = this, - view = view - ) + tabsTrayCtaBinding.set( + feature = TabsTrayInfoBannerBinding( + context = view.context, + store = requireComponents.core.store, + infoBannerView = tabsTrayBinding.infoBanner, + settings = requireComponents.settings, + navigationInteractor = navigationInteractor, + ), + owner = this, + view = view, + ) - val tabsTrayTabCounter2Binding = TabsTrayTabCounter2Binding.bind( - tabsTrayBinding.tabLayout - ) + tabLayoutMediator.set( + feature = TabLayoutMediator( + tabLayout = tabsTrayBinding.tabLayout, + tabPager = tabsTrayBinding.tabsTray, + interactor = tabsTrayInteractor, + browsingModeManager = activity.browsingModeManager, + tabsTrayStore = tabsTrayStore, + ), + owner = this, + view = view, + ) - tabCounterBinding.set( - feature = TabCounterBinding( - store = requireComponents.core.store, - counter = tabsTrayTabCounter2Binding.tabCounter - ), - owner = this, - view = view - ) + val tabsTrayTabCounter2Binding = TabsTrayTabCounter2Binding.bind( + tabsTrayBinding.tabLayout, + ) - floatingActionButtonBinding.set( - feature = FloatingActionButtonBinding( - store = tabsTrayStore, - actionButton = fabButtonBinding.newTabButton, - browserTrayInteractor = browserTrayInteractor - ), - owner = this, - view = view - ) + tabCounterBinding.set( + feature = TabCounterBinding( + store = requireComponents.core.store, + counter = tabsTrayTabCounter2Binding.tabCounter, + ), + owner = this, + view = view, + ) - val tabsTrayMultiselectItemsBinding = TabstrayMultiselectItemsBinding.bind( - tabsTrayBinding.root - ) + floatingActionButtonBinding.set( + feature = FloatingActionButtonBinding( + store = tabsTrayStore, + actionButton = fabButtonBinding.newTabButton, + interactor = tabsTrayInteractor, + isSignedIn = requireContext().settings().signedInFxaAccount, + ), + owner = this, + view = view, + ) - selectionBannerBinding.set( - feature = SelectionBannerBinding( - context = requireContext(), - binding = tabsTrayBinding, - store = tabsTrayStore, - navInteractor = navigationInteractor, - tabsTrayInteractor = tabsTrayInteractor, - backgroundView = tabsTrayBinding.topBar, - showOnSelectViews = VisibilityModifier( - tabsTrayMultiselectItemsBinding.collectMultiSelect, - tabsTrayMultiselectItemsBinding.shareMultiSelect, - tabsTrayMultiselectItemsBinding.menuMultiSelect, - tabsTrayBinding.multiselectTitle, - tabsTrayBinding.exitMultiSelect + val tabsTrayMultiselectItemsBinding = TabstrayMultiselectItemsBinding.bind( + tabsTrayBinding.root, + ) + + selectionBannerBinding.set( + feature = SelectionBannerBinding( + context = requireContext(), + binding = tabsTrayBinding, + store = tabsTrayStore, + interactor = tabsTrayInteractor, + backgroundView = tabsTrayBinding.topBar, + showOnSelectViews = VisibilityModifier( + tabsTrayMultiselectItemsBinding.collectMultiSelect, + tabsTrayMultiselectItemsBinding.shareMultiSelect, + tabsTrayMultiselectItemsBinding.menuMultiSelect, + tabsTrayBinding.multiselectTitle, + tabsTrayBinding.exitMultiSelect, + ), + showOnNormalViews = VisibilityModifier( + tabsTrayBinding.tabLayout, + tabsTrayBinding.tabTrayOverflow, + fabButtonBinding.newTabButton, + ), ), - showOnNormalViews = VisibilityModifier( - tabsTrayBinding.tabLayout, - tabsTrayBinding.tabTrayOverflow, - fabButtonBinding.newTabButton - ) - ), - owner = this, - view = view - ) + owner = this, + view = view, + ) - selectionHandleBinding.set( - feature = SelectionHandleBinding( - store = tabsTrayStore, - handle = tabsTrayBinding.handle, - containerLayout = tabsTrayBinding.tabWrapper + selectionHandleBinding.set( + feature = SelectionHandleBinding( + store = tabsTrayStore, + handle = tabsTrayBinding.handle, + containerLayout = tabsTrayBinding.tabWrapper, + ), + owner = this, + view = view, + ) + + tabsTrayInactiveTabsOnboardingBinding.set( + feature = TabsTrayInactiveTabsOnboardingBinding( + context = requireContext(), + store = requireComponents.core.store, + tabsTrayBinding = tabsTrayBinding, + settings = requireComponents.settings, + navigationInteractor = navigationInteractor, + ), + owner = this, + view = view, + ) + } + + tabsFeature.set( + feature = TabsFeature( + tabsTray = TabSorter( + requireContext().settings(), + tabsTrayStore, + ), + store = requireContext().components.core.store, ), owner = this, - view = view + view = view, ) secureTabsTrayBinding.set( @@ -354,22 +512,10 @@ class TabsTrayFragment : AppCompatDialogFragment() { store = tabsTrayStore, settings = requireComponents.settings, fragment = this, - dialog = dialog as TabsTrayDialog + dialog = dialog as TabsTrayDialog, ), owner = this, - view = view - ) - - tabsTrayInactiveTabsOnboardingBinding.set( - feature = TabsTrayInactiveTabsOnboardingBinding( - context = requireContext(), - store = requireComponents.core.store, - tabsTrayBinding = tabsTrayBinding, - settings = requireComponents.settings, - navigationInteractor = navigationInteractor - ), - owner = this, - view = view + view = view, ) syncedTabsIntegration.set( @@ -379,10 +525,10 @@ class TabsTrayFragment : AppCompatDialogFragment() { navController = findNavController(), storage = requireComponents.backgroundServices.syncedTabsStorage, accountManager = requireComponents.backgroundServices.accountManager, - lifecycleOwner = this + lifecycleOwner = this, ), owner = this, - view = view + view = view, ) setFragmentResultListener(ShareFragment.RESULT_KEY) { _, _ -> @@ -394,8 +540,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { super.onConfigurationChanged(newConfig) trayBehaviorManager.updateDependingOnOrientation(newConfig.orientation) - - if (requireContext().settings().gridTabView) { + if (!requireContext().settings().enableTabsTrayToCompose && requireContext().settings().gridTabView) { tabsTrayBinding.tabsTray.adapter?.notifyDataSetChanged() } } @@ -420,16 +565,16 @@ class TabsTrayFragment : AppCompatDialogFragment() { shouldWidthMatchParent = true, positiveButtonBackgroundColor = ThemeManager.resolveAttribute( R.attr.accent, - requireContext() + requireContext(), ), positiveButtonTextColor = ThemeManager.resolveAttribute( R.attr.textOnColorPrimary, - requireContext() + requireContext(), ), - positiveButtonRadius = (resources.getDimensionPixelSize(R.dimen.tab_corner_radius)).toFloat() + positiveButtonRadius = (resources.getDimensionPixelSize(R.dimen.tab_corner_radius)).toFloat(), ), - onPositiveButtonClicked = ::onCancelDownloadWarningAccepted + onPositiveButtonClicked = ::onCancelDownloadWarningAccepted, ) dialog.show(parentFragmentManager, DOWNLOAD_CANCEL_DIALOG_FRAGMENT_TAG) } @@ -441,47 +586,44 @@ class TabsTrayFragment : AppCompatDialogFragment() { true -> getString(R.string.snackbar_private_tab_closed) false -> getString(R.string.snackbar_tab_closed) } + val pagePosition = if (isPrivate) Page.PrivateTabs.ordinal else Page.NormalTabs.ordinal lifecycleScope.allowUndo( - requireView(), - snackbarMessage, - getString(R.string.snackbar_deleted_undo), - { + view = requireView(), + message = snackbarMessage, + undoActionTitle = getString(R.string.snackbar_deleted_undo), + onCancel = { requireComponents.useCases.tabsUseCases.undo.invoke() - tabLayoutMediator.withFeature { - it.selectTabAtPosition( - if (isPrivate) Page.PrivateTabs.ordinal else Page.NormalTabs.ordinal - ) + + if (requireContext().settings().enableTabsTrayToCompose) { + tabsTrayStore.dispatch(TabsTrayAction.PageSelected(Page.positionToPage(pagePosition))) + } else { + tabLayoutMediator.withFeature { + it.selectTabAtPosition(pagePosition) + } } }, operation = { }, elevation = ELEVATION, - anchorView = if (fabButtonBinding.newTabButton.isVisible) fabButtonBinding.newTabButton else null + anchorView = getSnackbarAnchor(), ) } @VisibleForTesting - @Suppress("LongParameterList") internal fun setupPager( context: Context, lifecycleOwner: LifecycleOwner, store: TabsTrayStore, trayInteractor: TabsTrayInteractor, - browserInteractor: BrowserTrayInteractor, - navigationInteractor: NavigationInteractor, - inactiveTabsInteractor: InactiveTabsInteractor ) { tabsTrayBinding.tabsTray.apply { adapter = TrayPagerAdapter( context = context, lifecycleOwner = lifecycleOwner, tabsTrayStore = store, - browserInteractor = browserInteractor, - navInteractor = navigationInteractor, - tabsTrayInteractor = trayInteractor, + interactor = trayInteractor, browserStore = requireComponents.core.store, appStore = requireComponents.appStore, - inactiveTabsInteractor = inactiveTabsInteractor, ) isUserInputEnabled = false } @@ -495,7 +637,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { browserStore = requireComponents.core.store, tabsTrayStore = tabsTrayStore, tabLayout = tabsTrayBinding.tabLayout, - navigationInteractor = navigationInteractor + navigationInteractor = navigationInteractor, ).build() menu.showWithTheme(anchor) @@ -508,13 +650,15 @@ class TabsTrayFragment : AppCompatDialogFragment() { browserStore: BrowserStore, tabsTrayStore: TabsTrayStore, tabLayout: TabLayout, - navigationInteractor: NavigationInteractor + navigationInteractor: NavigationInteractor, ) = MenuIntegration(context, browserStore, tabsTrayStore, tabLayout, navigationInteractor) @VisibleForTesting internal fun setupBackgroundDismissalListener(block: (View) -> Unit) { tabsTrayDialogBinding.tabLayout.setOnClickListener(block) - tabsTrayBinding.handle.setOnClickListener(block) + if (!requireContext().settings().enableTabsTrayToCompose) { + tabsTrayBinding.handle.setOnClickListener(block) + } } @VisibleForTesting @@ -534,13 +678,15 @@ class TabsTrayFragment : AppCompatDialogFragment() { @VisibleForTesting internal fun selectTabPosition(position: Int, smoothScroll: Boolean) { - tabsTrayBinding.tabsTray.setCurrentItem(position, smoothScroll) - tabsTrayBinding.tabLayout.getTabAt(position)?.select() + if (!requireContext().settings().enableTabsTrayToCompose) { + tabsTrayBinding.tabsTray.setCurrentItem(position, smoothScroll) + tabsTrayBinding.tabLayout.getTabAt(position)?.select() + } } @VisibleForTesting internal fun dismissTabsTray() { - // This should always be the last thing we do because nothing + // This should always be the last thing we do because nothing (e.g. telemetry) // is guaranteed after that. dismissAllowingStateLoss() } @@ -558,8 +704,8 @@ class TabsTrayFragment : AppCompatDialogFragment() { findNavController().navigate( TabsTrayFragmentDirections.actionGlobalHome( focusOnAddressBar = false, - scrollToCollection = true - ) + scrollToCollection = true, + ), ) dismissTabsTray() }.show() @@ -568,14 +714,14 @@ class TabsTrayFragment : AppCompatDialogFragment() { @VisibleForTesting internal fun showBookmarkSnackbar( - tabSize: Int + tabSize: Int, ) { WaterfoxSnackbar .make(requireView()) .bookmarkMessage(tabSize) .anchorWithAction(getSnackbarAnchor()) { findNavController().navigate( - TabsTrayFragmentDirections.actionGlobalBookmarkFragment(BookmarkRoot.Mobile.id) + TabsTrayFragmentDirections.actionGlobalBookmarkFragment(BookmarkRoot.Mobile.id), ) dismissTabsTray() } @@ -587,12 +733,25 @@ class TabsTrayFragment : AppCompatDialogFragment() { return parentFragmentManager.findFragmentByTag(DOWNLOAD_CANCEL_DIALOG_FRAGMENT_TAG) as? DownloadCancelDialogFragment } - private fun getSnackbarAnchor(): View? { - return if (requireComponents.settings.accessibilityServicesEnabled) { - null - } else { - fabButtonBinding.newTabButton - } + private fun getSnackbarAnchor(): View? = when { + requireContext().settings().enableTabsTrayToCompose -> fabButtonComposeBinding.root + fabButtonBinding.newTabButton.isVisible -> fabButtonBinding.newTabButton + else -> null + } + + private fun showInactiveTabsAutoCloseConfirmationSnackbar() { + val text = getString(R.string.inactive_tabs_auto_close_message_snackbar) + val snackbar = WaterfoxSnackbar.make( + view = tabsTrayComposeBinding.root, + duration = WaterfoxSnackbar.LENGTH_SHORT, + isDisplayedWithBrowserToolbar = true, + ).setText(text) + snackbar.view.elevation = ELEVATION + snackbar.show() + } + + private fun onTabsTrayDismissed() { + dismissAllowingStateLoss() } companion object { @@ -607,5 +766,7 @@ class TabsTrayFragment : AppCompatDialogFragment() { // Elevation for undo toasts @VisibleForTesting internal const val ELEVATION = 80f + + private const val TABS_TRAY_FEATURE_NAME = "Tabs tray" } } diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayInactiveTabsOnboardingBinding.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayInactiveTabsOnboardingBinding.kt index 56656e089..e632fdf10 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayInactiveTabsOnboardingBinding.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayInactiveTabsOnboardingBinding.kt @@ -37,7 +37,7 @@ class TabsTrayInactiveTabsOnboardingBinding( private val store: BrowserStore, private val tabsTrayBinding: ComponentTabstray2Binding?, private val settings: Settings, - private val navigationInteractor: NavigationInteractor + private val navigationInteractor: NavigationInteractor, ) : AbstractBinding(store) { private lateinit var inactiveTabsDialog: Dialog @@ -50,7 +50,7 @@ class TabsTrayInactiveTabsOnboardingBinding( .distinctUntilChanged() .collect { val inactiveTabsList = - if (settings.inactiveTabsAreEnabled) { store.state.potentialInactiveTabs } else emptyList() + if (settings.inactiveTabsAreEnabled) { store.state.potentialInactiveTabs } else { emptyList() } if (inactiveTabsList.isNotEmpty() && shouldShowOnboardingForInactiveTabs()) { createInactiveCFR() } diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayInfoBannerBinding.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayInfoBannerBinding.kt index b568658fe..e166c73d2 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayInfoBannerBinding.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayInfoBannerBinding.kt @@ -8,7 +8,6 @@ import android.content.Context import android.view.View.VISIBLE import android.view.ViewGroup import androidx.annotation.VisibleForTesting -import kotlin.math.max import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged @@ -21,6 +20,7 @@ import mozilla.components.lib.state.helpers.AbstractBinding import net.waterfox.android.R import net.waterfox.android.browser.infobanner.InfoBanner import net.waterfox.android.utils.Settings +import kotlin.math.max @OptIn(ExperimentalCoroutinesApi::class) class TabsTrayInfoBannerBinding( @@ -28,7 +28,7 @@ class TabsTrayInfoBannerBinding( store: BrowserStore, private val infoBannerView: ViewGroup, private val settings: Settings, - private val navigationInteractor: NavigationInteractor + private val navigationInteractor: NavigationInteractor, ) : AbstractBinding(store) { @VisibleForTesting @@ -67,7 +67,7 @@ class TabsTrayInfoBannerBinding( dismissByHiding = true, dismissAction = { settings.shouldShowAutoCloseTabsBanner = false - } + }, ) { navigationInteractor.onTabSettingsClicked() settings.shouldShowAutoCloseTabsBanner = false diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayInteractor.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayInteractor.kt index 7434180a7..1deec01de 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayInteractor.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayInteractor.kt @@ -5,10 +5,22 @@ package net.waterfox.android.tabstray import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.storage.sync.Tab +import mozilla.components.browser.tabstray.TabsTray +import net.waterfox.android.tabstray.browser.InactiveTabsInteractor +import net.waterfox.android.tabstray.browser.TabsTrayFabInteractor + +/** + * Interactor for responding to all user actions in the tabs tray. + */ +interface TabsTrayInteractor : + SyncedTabsInteractor, + TabsTray.Delegate, + InactiveTabsInteractor, + TabsTrayFabInteractor { -interface TabsTrayInteractor { /** - * Set the current tray item to the clamped [position]. + * Invoked when a page in the tabs tray is selected. * * @param position The position on the tray to focus. * @param smoothScroll If true, animate the scrolling from the current tab to [position]. @@ -16,79 +28,212 @@ interface TabsTrayInteractor { fun onTrayPositionSelected(position: Int, smoothScroll: Boolean) /** - * Dismisses the tabs tray and navigates to the browser. + * Invoked when the user confirmed tab removal that would lead to cancelled private downloads. + * + * @param tabId ID of the tab being removed. + * @param source is the app feature from which the [TabSessionState] with [tabId] was closed. */ - fun onBrowserTabSelected() + fun onDeletePrivateTabWarningAccepted(tabId: String, source: String? = null) /** - * Invoked when a tab is removed from the tabs tray with the given [tabId]. - * @param source app feature from which the [TabSessionState] with [tabId] was closed. + * Invoked when the selected tabs are requested to be deleted. */ - fun onDeleteTab(tabId: String, source: String? = null) + fun onDeleteSelectedTabsClicked() /** - * Invoked when the user confirmed tab removal that would lead to cancelled private downloads. - * @param source is the app feature from which the [TabSessionState] with [tabId] was closed. + * Invoked when the debug menu option for inactive tabs is clicked. */ - fun onDeletePrivateTabWarningAccepted(tabId: String, source: String? = null) + fun onForceSelectedTabsAsInactiveClicked() /** - * Invoked when [TabSessionState]s need to be deleted. + * Invoked when the bookmark button in the multi selection banner is clicked. */ - fun onDeleteTabs(tabs: Collection) + fun onBookmarkSelectedTabsClicked() /** - * Called when clicking the debug menu option for inactive tabs. + * Invoked when the collections button in the multi selection banner is clicked. */ - fun onInactiveDebugClicked(tabs: Collection) + fun onAddSelectedTabsToCollectionClicked() /** - * Invoked when [tabId] should be moved to before/after [targetId] from a drag-drop operation + * Invoked when the share button in the multi selection banner is clicked. + */ + fun onShareSelectedTabs() + + /** + * Invoked when a drag-drop operation with a tab is completed. + * + * @param tabId ID of the tab being moved. + * @param targetId ID of the tab the moved tab's new neighbor. + * @param placeAfter [Boolean] indicating whether the moved tab is being placed before or after [targetId]. */ fun onTabsMove( tabId: String, targetId: String?, - placeAfter: Boolean + placeAfter: Boolean, ) + + /** + * Invoked when the recently closed item is clicked. + */ + fun onRecentlyClosedClicked() + + /** + * Invoked when the a tab's media controls are clicked. + * + * @param tab [TabSessionState] to close. + */ + fun onMediaClicked(tab: TabSessionState) + + /** + * Invoked when a tab is long clicked. + * + * @param tab [TabSessionState] that was clicked. + */ + fun onTabLongClicked(tab: TabSessionState): Boolean + + /** + * Invoked when the back button is pressed. + * + * @return true if the back button press was consumed. + */ + fun onBackPressed(): Boolean + + /** + * Invoked when a tab is unselected. + * + * @param tab [TabSessionState] that was unselected. + */ + fun onTabUnselected(tab: TabSessionState) } /** - * Interactor to be called for any tabs tray user actions. + * Default implementation of [TabsTrayInteractor]. * - * @property controller [TabsTrayController] to which user actions can be delegated for actual app update. + * @param controller [TabsTrayController] to which user actions can be delegated for app updates. */ +@Suppress("TooManyFunctions") class DefaultTabsTrayInteractor( - private val controller: TabsTrayController + private val controller: TabsTrayController, ) : TabsTrayInteractor { + override fun onTrayPositionSelected(position: Int, smoothScroll: Boolean) { controller.handleTrayScrollingToPosition(position, smoothScroll) } - override fun onBrowserTabSelected() { - controller.handleNavigateToBrowser() - } - - override fun onDeleteTab(tabId: String, source: String?) { - controller.handleTabDeletion(tabId, source) - } - override fun onDeletePrivateTabWarningAccepted(tabId: String, source: String?) { controller.handleDeleteTabWarningAccepted(tabId, source) } - override fun onDeleteTabs(tabs: Collection) { - controller.handleMultipleTabsDeletion(tabs) + override fun onDeleteSelectedTabsClicked() { + controller.handleDeleteSelectedTabsClicked() } override fun onTabsMove( tabId: String, targetId: String?, - placeAfter: Boolean + placeAfter: Boolean, ) { controller.handleTabsMove(tabId, targetId, placeAfter) } - override fun onInactiveDebugClicked(tabs: Collection) { - controller.forceTabsAsInactive(tabs) + override fun onForceSelectedTabsAsInactiveClicked() { + controller.handleForceSelectedTabsAsInactiveClicked() + } + + override fun onBookmarkSelectedTabsClicked() { + controller.handleBookmarkSelectedTabsClicked() + } + + override fun onAddSelectedTabsToCollectionClicked() { + controller.handleAddSelectedTabsToCollectionClicked() + } + + override fun onShareSelectedTabs() { + controller.handleShareSelectedTabsClicked() + } + + override fun onSyncedTabClicked(tab: Tab) { + controller.handleSyncedTabClicked(tab) + } + + override fun onBackPressed(): Boolean = controller.handleBackPressed() + + override fun onTabClosed(tab: TabSessionState, source: String?) { + controller.handleTabDeletion(tab.id, source) + } + + override fun onTabSelected(tab: TabSessionState, source: String?) { + controller.handleTabSelected(tab, source) + } + + override fun onNormalTabsFabClicked() { + controller.handleNormalTabsFabClick() + } + + override fun onPrivateTabsFabClicked() { + controller.handlePrivateTabsFabClick() + } + + override fun onSyncedTabsFabClicked() { + controller.handleSyncedTabsFabClick() + } + + override fun onRecentlyClosedClicked() { + controller.handleNavigateToRecentlyClosed() + } + + override fun onMediaClicked(tab: TabSessionState) { + controller.handleMediaClicked(tab) + } + + override fun onTabLongClicked(tab: TabSessionState): Boolean { + return controller.handleTabLongClick(tab) + } + + override fun onTabUnselected(tab: TabSessionState) { + controller.handleTabUnselected(tab) + } + + /** + * See [InactiveTabsInteractor.onInactiveTabsHeaderClicked]. + */ + override fun onInactiveTabsHeaderClicked(expanded: Boolean) { + controller.handleInactiveTabsHeaderClicked(expanded) + } + + /** + * See [InactiveTabsInteractor.onAutoCloseDialogCloseButtonClicked]. + */ + override fun onAutoCloseDialogCloseButtonClicked() { + controller.handleInactiveTabsAutoCloseDialogDismiss() + } + + /** + * See [InactiveTabsInteractor.onEnableAutoCloseClicked]. + */ + override fun onEnableAutoCloseClicked() { + controller.handleEnableInactiveTabsAutoCloseClicked() + } + + /** + * See [InactiveTabsInteractor.onInactiveTabClicked]. + */ + override fun onInactiveTabClicked(tab: TabSessionState) { + controller.handleInactiveTabClicked(tab) + } + + /** + * See [InactiveTabsInteractor.onInactiveTabClosed]. + */ + override fun onInactiveTabClosed(tab: TabSessionState) { + controller.handleCloseInactiveTabClicked(tab) + } + + /** + * See [InactiveTabsInteractor.onDeleteAllInactiveTabsClicked]. + */ + override fun onDeleteAllInactiveTabsClicked() { + controller.handleDeleteAllInactiveTabsClicked() } } diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayMenu.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayMenu.kt index b1463008e..81c5afeba 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayMenu.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayMenu.kt @@ -20,7 +20,7 @@ class TabsTrayMenu( private val context: Context, browserStore: BrowserStore, private val tabLayout: TabLayout, - private val onItemTapped: (Item) -> Unit = {} + private val onItemTapped: (Item) -> Unit = {}, ) { private val checkOpenTabs = @@ -52,45 +52,45 @@ class TabsTrayMenu( listOf( SimpleBrowserMenuItem( context.getString(R.string.tabs_tray_select_tabs), - textColorResource = R.color.fx_mobile_text_color_primary + textColorResource = R.color.fx_mobile_text_color_primary, ) { onItemTapped.invoke(Item.SelectTabs) }.apply { visible = shouldShowSelectOrShare }, SimpleBrowserMenuItem( context.getString(R.string.tab_tray_menu_item_share), - textColorResource = R.color.fx_mobile_text_color_primary + textColorResource = R.color.fx_mobile_text_color_primary, ) { onItemTapped.invoke(Item.ShareAllTabs) }.apply { visible = shouldShowSelectOrShare }, SimpleBrowserMenuItem( context.getString(R.string.tab_tray_menu_account_settings), - textColorResource = R.color.fx_mobile_text_color_primary + textColorResource = R.color.fx_mobile_text_color_primary, ) { onItemTapped.invoke(Item.OpenAccountSettings) }.apply { visible = shouldShowAccountSetting }, SimpleBrowserMenuItem( context.getString(R.string.tab_tray_menu_tab_settings), - textColorResource = R.color.fx_mobile_text_color_primary + textColorResource = R.color.fx_mobile_text_color_primary, ) { onItemTapped.invoke(Item.OpenTabSettings) }.apply { visible = shouldShowTabSetting }, SimpleBrowserMenuItem( context.getString(R.string.tab_tray_menu_recently_closed), - textColorResource = R.color.fx_mobile_text_color_primary + textColorResource = R.color.fx_mobile_text_color_primary, ) { onItemTapped.invoke(Item.OpenRecentlyClosed) }, SimpleBrowserMenuItem( context.getString(R.string.tab_tray_menu_item_close), - textColorResource = R.color.fx_mobile_text_color_primary + textColorResource = R.color.fx_mobile_text_color_primary, ) { onItemTapped.invoke(Item.CloseAllTabs) - }.apply { visible = { checkOpenTabs } } + }.apply { visible = { checkOpenTabs } }, ) } } diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayMiddleware.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayMiddleware.kt deleted file mode 100644 index 94d98989b..000000000 --- a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayMiddleware.kt +++ /dev/null @@ -1,36 +0,0 @@ -/* 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 net.waterfox.android.tabstray - -import mozilla.components.lib.state.Middleware -import mozilla.components.lib.state.MiddlewareContext - -/** - * [Middleware] that reacts to various [TabsTrayAction]s. - */ -class TabsTrayMiddleware : Middleware { - - private var shouldReportInactiveTabMetrics: Boolean = true - - override fun invoke( - context: MiddlewareContext, - next: (TabsTrayAction) -> Unit, - action: TabsTrayAction - ) { - next(action) - - when (action) { - is TabsTrayAction.UpdateInactiveTabs -> { - if (shouldReportInactiveTabMetrics) { - shouldReportInactiveTabMetrics = false - } - } - else -> { - // no-op - } - } - } - -} diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayStore.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayStore.kt index 7c3365514..72014c659 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayStore.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayStore.kt @@ -21,6 +21,7 @@ import net.waterfox.android.tabstray.syncedtabs.SyncedTabsListItem * @property inactiveTabs The list of tabs are considered inactive. * @property normalTabs The list of normal tabs that do not fall under [inactiveTabs]. * @property privateTabs The list of tabs that are [ContentState.private]. + * @property syncedTabs The list of synced tabs. * @property syncing Whether the Synced Tabs feature should fetch the latest tabs from paired devices. */ data class TabsTrayState( @@ -74,7 +75,9 @@ enum class Page { /** * The pager position that displays Synced Tabs. */ - SyncedTabs; + SyncedTabs, + + ; companion object { fun positionToPage(position: Int): Page { @@ -168,7 +171,7 @@ internal object TabsTrayReducer { TabsTrayState.Mode.Normal } else { TabsTrayState.Mode.Select(selected) - } + }, ) } is TabsTrayAction.PageSelected -> @@ -195,9 +198,9 @@ internal object TabsTrayReducer { */ class TabsTrayStore( initialState: TabsTrayState = TabsTrayState(), - middlewares: List> = emptyList() + middlewares: List> = emptyList(), ) : Store( initialState, TabsTrayReducer::reduce, - middlewares + middlewares, ) diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayTabLayouts.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayTabLayouts.kt new file mode 100644 index 000000000..b30ba0597 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayTabLayouts.kt @@ -0,0 +1,463 @@ +/* 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 net.waterfox.android.tabstray + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.dp +import mozilla.components.browser.state.state.ContentState +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.thumbnails.storage.ThumbnailStorage +import net.waterfox.android.R +import net.waterfox.android.compose.annotation.LightDarkPreview +import net.waterfox.android.compose.tabstray.TabGridItem +import net.waterfox.android.compose.tabstray.TabListItem +import net.waterfox.android.tabstray.browser.compose.DragItemContainer +import net.waterfox.android.tabstray.browser.compose.createGridReorderState +import net.waterfox.android.tabstray.browser.compose.createListReorderState +import net.waterfox.android.tabstray.browser.compose.detectGridPressAndDragGestures +import net.waterfox.android.tabstray.browser.compose.detectVerticalPressAndDrag +import net.waterfox.android.tabstray.ext.MIN_COLUMN_WIDTH_DP +import net.waterfox.android.tabstray.ext.numberOfGridColumns +import net.waterfox.android.theme.WaterfoxTheme +import kotlin.math.max + +// Key for the span item at the bottom of the tray, used to make the item not reorderable. +const val SPAN_ITEM_KEY = "span" + +// Key for the header item at the top of the tray, used to make the item not reorderable. +const val HEADER_ITEM_KEY = "header" + +/** + * Top-level UI for displaying a list of tabs. + * + * @param tabs The list of [TabSessionState] to display. + * @param storage [ThumbnailStorage] to obtain tab thumbnail bitmaps from. + * @param displayTabsInGrid Whether the tabs should be displayed in a grid. + * @param selectedTabId The ID of the currently selected tab. + * @param selectionMode [TabsTrayState.Mode] indicating whether the Tabs Tray is in single selection + * or multi-selection and contains the set of selected tabs. + * @param modifier [Modifier] to be applied to the layout. + * @param onTabClose Invoked when the user clicks to close a tab. + * @param onTabMediaClick Invoked when the user interacts with a tab's media controls. + * @param onTabClick Invoked when the user clicks on a tab. + * @param onTabLongClick Invoked when the user long clicks a tab. + * @param onMove Invoked when the user moves a tab. + * @param onTabDragStart Invoked when starting to drag a tab. + * @param header Optional layout to display before [tabs]. + */ +@Suppress("LongParameterList") +@Composable +fun TabLayout( + tabs: List, + storage: ThumbnailStorage, + displayTabsInGrid: Boolean, + selectedTabId: String?, + selectionMode: TabsTrayState.Mode, + modifier: Modifier = Modifier, + onTabClose: (TabSessionState) -> Unit, + onTabMediaClick: (TabSessionState) -> Unit, + onTabClick: (TabSessionState) -> Unit, + onTabLongClick: (TabSessionState) -> Unit, + onMove: (String, String?, Boolean) -> Unit, + onTabDragStart: () -> Unit, + header: (@Composable () -> Unit)? = null, +) { + var selectedTabIndex = 0 + selectedTabId?.let { + tabs.forEachIndexed { index, tab -> + if (tab.id == selectedTabId) { + selectedTabIndex = index + return@forEachIndexed + } + } + } + + if (displayTabsInGrid) { + TabGrid( + tabs = tabs, + storage = storage, + selectedTabId = selectedTabId, + selectedTabIndex = selectedTabIndex, + selectionMode = selectionMode, + modifier = modifier, + onTabClose = onTabClose, + onTabMediaClick = onTabMediaClick, + onTabClick = onTabClick, + onTabLongClick = onTabLongClick, + onMove = onMove, + onTabDragStart = onTabDragStart, + header = header, + ) + } else { + TabList( + tabs = tabs, + storage = storage, + selectedTabId = selectedTabId, + selectedTabIndex = selectedTabIndex, + selectionMode = selectionMode, + modifier = modifier, + onTabClose = onTabClose, + onTabMediaClick = onTabMediaClick, + onTabClick = onTabClick, + onTabLongClick = onTabLongClick, + onMove = onMove, + onTabDragStart = onTabDragStart, + header = header, + ) + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Suppress("LongParameterList", "LongMethod") +@Composable +private fun TabGrid( + tabs: List, + storage: ThumbnailStorage, + selectedTabId: String?, + selectedTabIndex: Int, + selectionMode: TabsTrayState.Mode, + modifier: Modifier = Modifier, + onTabClose: (TabSessionState) -> Unit, + onTabMediaClick: (TabSessionState) -> Unit, + onTabClick: (TabSessionState) -> Unit, + onTabLongClick: (TabSessionState) -> Unit, + onMove: (String, String?, Boolean) -> Unit, + onTabDragStart: () -> Unit, + header: (@Composable () -> Unit)? = null, +) { + val state = rememberLazyGridState(initialFirstVisibleItemIndex = selectedTabIndex) + val tabListBottomPadding = dimensionResource(id = R.dimen.tab_tray_list_bottom_padding) + val tabThumbnailSize = max( + LocalContext.current.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_height), + LocalContext.current.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_width), + ) + val isInMultiSelectMode = selectionMode is TabsTrayState.Mode.Select + + val reorderState = createGridReorderState( + gridState = state, + onMove = { initialTab, newTab -> + onMove( + (initialTab.key as String), + (newTab.key as String), + initialTab.index < newTab.index, + ) + }, + onLongPress = { itemInfo -> + tabs.firstOrNull { tab -> tab.id == itemInfo.key }?.let { tab -> + onTabLongClick(tab) + } + }, + onExitLongPress = onTabDragStart, + ignoredItems = listOf(HEADER_ITEM_KEY, SPAN_ITEM_KEY), + ) + var shouldLongPress by remember { mutableStateOf(!isInMultiSelectMode) } + LaunchedEffect(selectionMode, reorderState.draggingItemKey) { + if (reorderState.draggingItemKey == null) { + shouldLongPress = selectionMode == TabsTrayState.Mode.Normal + } + } + + LazyVerticalGrid( + columns = GridCells.Fixed(count = LocalContext.current.numberOfGridColumns), + modifier = modifier + .fillMaxSize() + .detectGridPressAndDragGestures( + gridState = state, + reorderState = reorderState, + shouldLongPressToDrag = shouldLongPress, + ), + state = state, + ) { + header?.let { + item(key = HEADER_ITEM_KEY, span = { GridItemSpan(maxLineSpan) }) { + header() + } + } + + itemsIndexed( + items = tabs, + key = { _, tab -> tab.id }, + ) { index, tab -> + DragItemContainer( + state = reorderState, + position = index + if (header != null) 1 else 0, + key = tab.id, + ) { + TabGridItem( + tab = tab, + thumbnailSize = tabThumbnailSize, + storage = storage, + isSelected = tab.id == selectedTabId, + multiSelectionEnabled = isInMultiSelectMode, + multiSelectionSelected = selectionMode.selectedTabs.contains(tab), + shouldClickListen = reorderState.draggingItemKey != tab.id, + onCloseClick = onTabClose, + onMediaClick = onTabMediaClick, + onClick = onTabClick, + ) + } + } + + item(key = SPAN_ITEM_KEY, span = { GridItemSpan(maxLineSpan) }) { + Spacer(modifier = Modifier.height(tabListBottomPadding)) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Suppress("LongParameterList") +@Composable +private fun TabList( + tabs: List, + storage: ThumbnailStorage, + selectedTabId: String?, + selectedTabIndex: Int, + selectionMode: TabsTrayState.Mode, + modifier: Modifier = Modifier, + onTabClose: (TabSessionState) -> Unit, + onTabMediaClick: (TabSessionState) -> Unit, + onTabClick: (TabSessionState) -> Unit, + onTabLongClick: (TabSessionState) -> Unit, + onMove: (String, String?, Boolean) -> Unit, + header: (@Composable () -> Unit)? = null, + onTabDragStart: () -> Unit = {}, +) { + val state = rememberLazyListState(initialFirstVisibleItemIndex = selectedTabIndex) + val tabListBottomPadding = dimensionResource(id = R.dimen.tab_tray_list_bottom_padding) + val tabThumbnailSize = max( + LocalContext.current.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_height), + LocalContext.current.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_width), + ) + val isInMultiSelectMode = selectionMode is TabsTrayState.Mode.Select + val reorderState = createListReorderState( + listState = state, + onMove = { initialTab, newTab -> + onMove( + (initialTab.key as String), + (newTab.key as String), + initialTab.index < newTab.index, + ) + }, + onLongPress = { + tabs.firstOrNull { tab -> tab.id == it.key }?.let { tab -> + onTabLongClick(tab) + } + }, + onExitLongPress = onTabDragStart, + ignoredItems = listOf(HEADER_ITEM_KEY, SPAN_ITEM_KEY), + ) + var shouldLongPress by remember { mutableStateOf(!isInMultiSelectMode) } + LaunchedEffect(selectionMode, reorderState.draggingItemKey) { + if (reorderState.draggingItemKey == null) { + shouldLongPress = selectionMode == TabsTrayState.Mode.Normal + } + } + + LazyColumn( + modifier = modifier + .fillMaxSize() + .detectVerticalPressAndDrag( + listState = state, + reorderState = reorderState, + shouldLongPressToDrag = shouldLongPress, + ), + state = state, + ) { + header?.let { + item(key = HEADER_ITEM_KEY) { + header() + } + } + + itemsIndexed( + items = tabs, + key = { _, tab -> tab.id }, + ) { index, tab -> + DragItemContainer( + state = reorderState, + position = index + if (header != null) 1 else 0, + key = tab.id, + ) { + TabListItem( + tab = tab, + thumbnailSize = tabThumbnailSize, + storage = storage, + isSelected = tab.id == selectedTabId, + multiSelectionEnabled = isInMultiSelectMode, + multiSelectionSelected = selectionMode.selectedTabs.contains(tab), + shouldClickListen = reorderState.draggingItemKey != tab.id, + onCloseClick = onTabClose, + onMediaClick = onTabMediaClick, + onClick = onTabClick, + ) + } + } + + item(key = SPAN_ITEM_KEY) { + Spacer(modifier = Modifier.height(tabListBottomPadding)) + } + } +} + +@LightDarkPreview +@Composable +private fun TabListPreview() { + val tabs = remember { generateFakeTabsList().toMutableStateList() } + + WaterfoxTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(WaterfoxTheme.colors.layer1), + ) { + TabLayout( + tabs = tabs, + storage = ThumbnailStorage(LocalContext.current), + selectedTabId = tabs[1].id, + selectionMode = TabsTrayState.Mode.Normal, + displayTabsInGrid = false, + onTabClose = tabs::remove, + onTabMediaClick = {}, + onTabClick = {}, + onTabLongClick = {}, + onTabDragStart = {}, + onMove = { _, _, _ -> }, + ) + } + } +} + +@LightDarkPreview +@Composable +private fun TabGridPreview() { + val tabs = remember { generateFakeTabsList().toMutableStateList() } + + WaterfoxTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(WaterfoxTheme.colors.layer1), + ) { + TabLayout( + tabs = tabs, + storage = ThumbnailStorage(LocalContext.current), + selectedTabId = tabs[0].id, + selectionMode = TabsTrayState.Mode.Normal, + displayTabsInGrid = false, + onTabClose = tabs::remove, + onTabMediaClick = {}, + onTabClick = {}, + onTabLongClick = {}, + onTabDragStart = {}, + onMove = { _, _, _ -> }, + ) + } + } +} + +@LightDarkPreview +@Composable +private fun TabGridSmallPreview() { + val tabs = remember { generateFakeTabsList().toMutableStateList() } + val width = MIN_COLUMN_WIDTH_DP.dp + 50.dp + + WaterfoxTheme { + Box( + modifier = Modifier + .fillMaxHeight() + .width(width) + .background(WaterfoxTheme.colors.layer1), + ) { + TabLayout( + tabs = tabs, + storage = ThumbnailStorage(LocalContext.current), + selectedTabId = tabs[0].id, + selectionMode = TabsTrayState.Mode.Normal, + displayTabsInGrid = true, + onTabClose = tabs::remove, + onTabMediaClick = {}, + onTabClick = {}, + onTabLongClick = {}, + onTabDragStart = {}, + onMove = { _, _, _ -> }, + ) + } + } +} + +@Suppress("MagicNumber") +@LightDarkPreview +@Composable +private fun TabGridMultiSelectPreview() { + val tabs = generateFakeTabsList() + val selectedTabs = remember { tabs.take(4).toMutableStateList() } + + WaterfoxTheme { + Box( + modifier = Modifier + .fillMaxSize() + .background(WaterfoxTheme.colors.layer1), + ) { + TabLayout( + tabs = tabs, + storage = ThumbnailStorage(LocalContext.current), + selectedTabId = tabs[0].id, + selectionMode = TabsTrayState.Mode.Select(selectedTabs.toSet()), + displayTabsInGrid = false, + onTabClose = {}, + onTabMediaClick = {}, + onTabClick = { tab -> + if (selectedTabs.contains(tab)) { + selectedTabs.remove(tab) + } else { + selectedTabs.add(tab) + } + }, + onTabLongClick = {}, + onTabDragStart = {}, + onMove = { _, _, _ -> }, + ) + } + } +} + +private fun generateFakeTabsList( + tabCount: Int = 10, + isPrivate: Boolean = false, +): List = + List(tabCount) { index -> + TabSessionState( + id = "tabId$index-$isPrivate", + content = ContentState( + url = "www.mozilla.com", + private = isPrivate, + ), + ) + } diff --git a/app/src/main/java/net/waterfox/android/tabstray/TabsTrayTestTag.kt b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayTestTag.kt new file mode 100644 index 000000000..83f8ba9d0 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/tabstray/TabsTrayTestTag.kt @@ -0,0 +1,47 @@ +/* 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 net.waterfox.android.tabstray + +internal object TabsTrayTestTag { + const val tabsTray = "tabstray" + + // Tabs Tray Banner + private const val bannerTestTagRoot = "$tabsTray.banner" + const val bannerHandle = "$bannerTestTagRoot.handle" + const val normalTabsPageButton = "$bannerTestTagRoot.normalTabsPageButton" + const val normalTabsCounter = "$normalTabsPageButton.counter" + const val privateTabsPageButton = "$bannerTestTagRoot.privateTabsPageButton" + const val syncedTabsPageButton = "$bannerTestTagRoot.syncedTabsPageButton" + + const val selectionCounter = "$bannerTestTagRoot.selectionCounter" + const val collectionsButton = "$bannerTestTagRoot.collections" + + // Tabs Tray Banner three dot menu + const val threeDotButton = "$bannerTestTagRoot.threeDotButton" + + const val accountSettings = "$threeDotButton.accountSettings" + const val closeAllTabs = "$threeDotButton.closeAllTabs" + const val recentlyClosedTabs = "$threeDotButton.recentlyClosedTabs" + const val selectTabs = "$threeDotButton.selectTabs" + const val shareAllTabs = "$threeDotButton.shareAllTabs" + const val tabSettings = "$threeDotButton.tabSettings" + + // FAB + const val fab = "$tabsTray.fab" + + // Tab lists + private const val tabListTestTagRoot = "$tabsTray.tabList" + const val normalTabsList = "$tabListTestTagRoot.normal" + const val privateTabsList = "$tabListTestTagRoot.private" + const val syncedTabsList = "$tabListTestTagRoot.synced" + + const val emptyNormalTabsList = "$normalTabsList.empty" + const val emptyPrivateTabsList = "$privateTabsList.empty" + + // Tab items + const val tabItemRoot = "$tabsTray.tabItem" + const val tabItemClose = "$tabItemRoot.close" + const val tabItemThumbnail = "$tabItemRoot.thumbnail" +} diff --git a/app/src/main/java/net/waterfox/android/tabstray/TrayPagerAdapter.kt b/app/src/main/java/net/waterfox/android/tabstray/TrayPagerAdapter.kt index c204ee548..5b043db5c 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/TrayPagerAdapter.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/TrayPagerAdapter.kt @@ -7,7 +7,6 @@ package net.waterfox.android.tabstray import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup -import androidx.annotation.VisibleForTesting import androidx.compose.ui.platform.ComposeView import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.ConcatAdapter @@ -15,25 +14,19 @@ import androidx.recyclerview.widget.RecyclerView import mozilla.components.browser.state.store.BrowserStore import net.waterfox.android.components.AppStore import net.waterfox.android.tabstray.browser.BrowserTabsAdapter -import net.waterfox.android.tabstray.browser.BrowserTrayInteractor import net.waterfox.android.tabstray.browser.InactiveTabsAdapter -import net.waterfox.android.tabstray.browser.InactiveTabsInteractor import net.waterfox.android.tabstray.viewholders.AbstractPageViewHolder import net.waterfox.android.tabstray.viewholders.NormalBrowserPageViewHolder import net.waterfox.android.tabstray.viewholders.PrivateBrowserPageViewHolder import net.waterfox.android.tabstray.viewholders.SyncedTabsPageViewHolder -@Suppress("LongParameterList") class TrayPagerAdapter( - @get:VisibleForTesting internal val context: Context, - @get:VisibleForTesting internal val lifecycleOwner: LifecycleOwner, - @get:VisibleForTesting internal val tabsTrayStore: TabsTrayStore, - @get:VisibleForTesting internal val browserInteractor: BrowserTrayInteractor, - @get:VisibleForTesting internal val navInteractor: NavigationInteractor, - @get:VisibleForTesting internal val tabsTrayInteractor: TabsTrayInteractor, - @get:VisibleForTesting internal val browserStore: BrowserStore, - @get:VisibleForTesting internal val appStore: AppStore, - @get:VisibleForTesting internal val inactiveTabsInteractor: InactiveTabsInteractor, + internal val context: Context, + internal val lifecycleOwner: LifecycleOwner, + internal val tabsTrayStore: TabsTrayStore, + internal val interactor: TabsTrayInteractor, + internal val browserStore: BrowserStore, + internal val appStore: AppStore, ) : RecyclerView.Adapter() { /** @@ -46,18 +39,20 @@ class TrayPagerAdapter( InactiveTabsAdapter( lifecycleOwner = lifecycleOwner, tabsTrayStore = tabsTrayStore, - inactiveTabsInteractor = inactiveTabsInteractor + interactor = interactor, + featureName = INACTIVE_TABS_FEATURE_NAME, ), - BrowserTabsAdapter(context, browserInteractor, tabsTrayStore, lifecycleOwner) + BrowserTabsAdapter(context, interactor, tabsTrayStore, TABS_TRAY_FEATURE_NAME, lifecycleOwner), ) } private val privateAdapter by lazy { BrowserTabsAdapter( context, - browserInteractor, + interactor, tabsTrayStore, - lifecycleOwner + TABS_TRAY_FEATURE_NAME, + lifecycleOwner, ) } @@ -70,7 +65,7 @@ class TrayPagerAdapter( tabsTrayStore, browserStore, appStore, - tabsTrayInteractor + interactor, ) } PrivateBrowserPageViewHolder.LAYOUT_ID -> { @@ -78,7 +73,7 @@ class TrayPagerAdapter( LayoutInflater.from(parent.context).inflate(viewType, parent, false), tabsTrayStore, browserStore, - tabsTrayInteractor + interactor, ) } SyncedTabsPageViewHolder.LAYOUT_ID -> { @@ -86,11 +81,11 @@ class TrayPagerAdapter( composeView = ComposeView(parent.context).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT + ViewGroup.LayoutParams.MATCH_PARENT, ) }, tabsTrayStore = tabsTrayStore, - navigationInteractor = navInteractor + interactor = interactor, ) } else -> throw IllegalStateException("Unknown viewType.") @@ -131,6 +126,10 @@ class TrayPagerAdapter( companion object { const val TRAY_TABS_COUNT = 3 + // Keys for identifying from which app features the a was opened / closed. + const val TABS_TRAY_FEATURE_NAME = "Tabs tray" + const val INACTIVE_TABS_FEATURE_NAME = "Inactive tabs" + val POSITION_NORMAL_TABS = Page.NormalTabs.ordinal val POSITION_PRIVATE_TABS = Page.PrivateTabs.ordinal val POSITION_SYNCED_TABS = Page.SyncedTabs.ordinal diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/AbstractBrowserTabViewHolder.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/AbstractBrowserTabViewHolder.kt index 3c4a3eaac..60dc52ca8 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/AbstractBrowserTabViewHolder.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/AbstractBrowserTabViewHolder.kt @@ -29,11 +29,18 @@ import mozilla.components.concept.base.images.ImageLoadRequest import mozilla.components.concept.base.images.ImageLoader import mozilla.components.concept.engine.mediasession.MediaSession import mozilla.components.support.ktx.kotlin.MAX_URI_LENGTH +import mozilla.components.support.ktx.kotlin.toShortUrl import net.waterfox.android.R -import net.waterfox.android.ext.* +import net.waterfox.android.ext.components +import net.waterfox.android.ext.increaseTapArea +import net.waterfox.android.ext.removeAndDisable +import net.waterfox.android.ext.removeTouchDelegate +import net.waterfox.android.ext.showAndEnable import net.waterfox.android.selection.SelectionHolder +import net.waterfox.android.tabstray.TabsTrayInteractor import net.waterfox.android.tabstray.TabsTrayState import net.waterfox.android.tabstray.TabsTrayStore +import net.waterfox.android.tabstray.ext.toDisplayTitle /** * A RecyclerView ViewHolder implementation for "tab" items. @@ -41,14 +48,16 @@ import net.waterfox.android.tabstray.TabsTrayStore * @param itemView [View] that displays a "tab". * @param imageLoader [ImageLoader] used to load tab thumbnails. * @param trayStore [TabsTrayStore] containing the complete state of tabs tray and methods to update that. + * @param selectionHolder [SelectionHolder] instance containing the selected tabs in the tabs tray. + * @property featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. * @param store [BrowserStore] containing the complete state of the browser and methods to update that. */ -@Suppress("LongParameterList") abstract class AbstractBrowserTabViewHolder( itemView: View, private val imageLoader: ImageLoader, private val trayStore: TabsTrayStore, private val selectionHolder: SelectionHolder?, + internal val featureName: String, private val store: BrowserStore = itemView.context.components.core.store, ) : SelectableTabViewHolder(itemView) { @@ -64,7 +73,7 @@ abstract class AbstractBrowserTabViewHolder( internal val urlView: TextView? = itemView.findViewById(R.id.mozac_browser_tabstray_url) private val playPauseButtonView: ImageButton = itemView.findViewById(R.id.play_pause_button) - abstract val browserTrayInteractor: BrowserTrayInteractor + abstract val interactor: TabsTrayInteractor abstract val thumbnailSize: Int override var tab: TabSessionState? = null @@ -80,7 +89,7 @@ abstract class AbstractBrowserTabViewHolder( tab: TabSessionState, isSelected: Boolean, styling: TabsTrayStyling, - delegate: TabsTray.Delegate + delegate: TabsTray.Delegate, ) { this.tab = tab beingDragged = false @@ -93,10 +102,10 @@ abstract class AbstractBrowserTabViewHolder( updateMediaState(tab) if (selectionHolder != null) { - setSelectionInteractor(tab, selectionHolder, browserTrayInteractor) + setSelectionInteractor(tab, selectionHolder, interactor) } else { itemView.setOnClickListener { - browserTrayInteractor.onTabSelected(tab) + interactor.onTabSelected(tab, featureName) } } @@ -106,6 +115,7 @@ abstract class AbstractBrowserTabViewHolder( override fun showTabIsMultiSelectEnabled(selectedMaskView: View?, isSelected: Boolean) { selectedMaskView?.isVisible = isSelected closeView.isInvisible = trayStore.state.mode is TabsTrayState.Mode.Select + closeView.isClickable = trayStore.state.mode !is TabsTrayState.Mode.Select } private fun updateFavicon(tab: TabSessionState) { @@ -118,9 +128,8 @@ abstract class AbstractBrowserTabViewHolder( } private fun updateTitle(tab: TabSessionState) { - val title = tab.content.title.ifEmpty { - tab.content.url - } + // We can use the max URI length for titles as well. + val title = tab.toDisplayTitle().take(MAX_URI_LENGTH) titleView.text = title } @@ -153,7 +162,7 @@ abstract class AbstractBrowserTabViewHolder( contentDescription = context.getString(R.string.mozac_feature_media_notification_action_play) setImageDrawable( - AppCompatResources.getDrawable(context, R.drawable.media_state_play) + AppCompatResources.getDrawable(context, R.drawable.media_state_play), ) } @@ -162,7 +171,7 @@ abstract class AbstractBrowserTabViewHolder( contentDescription = context.getString(R.string.mozac_feature_media_notification_action_pause) setImageDrawable( - AppCompatResources.getDrawable(context, R.drawable.media_state_pause) + AppCompatResources.getDrawable(context, R.drawable.media_state_pause), ) } @@ -182,7 +191,7 @@ abstract class AbstractBrowserTabViewHolder( sessionState.mediaSessionState?.controller?.play() } else -> throw AssertionError( - "Play/Pause button clicked without play/pause state." + "Play/Pause button clicked without play/pause state.", ) } } @@ -196,14 +205,14 @@ abstract class AbstractBrowserTabViewHolder( private fun setSelectionInteractor( item: TabSessionState, holder: SelectionHolder, - interactor: BrowserTrayInteractor + interactor: TabsTrayInteractor, ) { itemView.setOnClickListener { - interactor.onMultiSelectClicked(item, holder, null) + interactor.onTabSelected(item, featureName) } itemView.setOnLongClickListener { - interactor.onLongClicked(item, holder) + interactor.onTabLongClicked(item) } setDragInteractor(item, holder, interactor) } @@ -212,7 +221,7 @@ abstract class AbstractBrowserTabViewHolder( private fun setDragInteractor( item: TabSessionState, holder: SelectionHolder, - interactor: BrowserTrayInteractor + interactor: TabsTrayInteractor, ) { // Since I immediately pass the event to onTouchEvent if it's not a move // The ClickableViewAccessibility warning isn't useful @@ -237,7 +246,7 @@ abstract class AbstractBrowserTabViewHolder( // Only start deselect+drag if the user drags far enough val dist = PointF.length(touchStart.x - motionEvent.x, touchStart.y - motionEvent.y) if (dist > ViewConfiguration.get(parent.context).scaledTouchSlop) { - interactor.deselect(item) // Exit selection mode + interactor.onTabUnselected(item) // Exit selection mode touchStartPoint = null val dragOffset = PointF(motionEvent.x, motionEvent.y) val shadow = BlankDragShadowBuilder() diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/AbstractBrowserTrayList.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/AbstractBrowserTrayList.kt index 2f38e373b..f9af6b2ea 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/AbstractBrowserTrayList.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/AbstractBrowserTrayList.kt @@ -23,7 +23,7 @@ import kotlin.math.abs abstract class AbstractBrowserTrayList @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = 0 + defStyleAttr: Int = 0, ) : RecyclerView(context, attrs, defStyleAttr) { lateinit var interactor: TabsTrayInteractor @@ -148,7 +148,9 @@ abstract class AbstractBrowserTrayList @JvmOverloads constructor( false } } - } else false + } else { + false + } } private val dragRunnable: Runnable = object : Runnable { @@ -176,7 +178,10 @@ abstract class AbstractBrowserTrayList @JvmOverloads constructor( // Deal with https://issuetracker.google.com/issues/37018279 // See also https://stackoverflow.com/questions/27992427 (layoutManager as? ItemTouchHelper.ViewDropHandler)?.prepareForDrop( - sourceView, targetView, sourceView.left, sourceView.top + sourceView, + targetView, + sourceView.left, + sourceView.top, ) } } diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/BrowserTabViewHolder.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/BrowserTabViewHolder.kt new file mode 100644 index 000000000..c4761a7c0 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/BrowserTabViewHolder.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 net.waterfox.android.tabstray.browser + +import android.view.View +import androidx.appcompat.content.res.AppCompatResources +import androidx.appcompat.widget.AppCompatImageButton +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.tabstray.TabsTray +import mozilla.components.browser.tabstray.TabsTrayStyling +import mozilla.components.concept.base.images.ImageLoader +import net.waterfox.android.R +import net.waterfox.android.databinding.TabTrayGridItemBinding +import net.waterfox.android.ext.increaseTapArea +import net.waterfox.android.selection.SelectionHolder +import net.waterfox.android.tabstray.TabsTrayInteractor +import net.waterfox.android.tabstray.TabsTrayStore +import kotlin.math.max + +sealed class BrowserTabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + /** + * A RecyclerView ViewHolder implementation for "tab" items with grid layout. + * + * @param imageLoader [ImageLoader] used to load tab thumbnails. + * @property interactor [TabsTrayInteractor] handling tabs interactions in a tab tray. + * @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that. + * @param selectionHolder [SelectionHolder]<[TabSessionState]> for helping with selecting + * any number of displayed [TabSessionState]s. + * @param itemView [View] that displays a "tab". + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + */ + class GridViewHolder( + imageLoader: ImageLoader, + override val interactor: TabsTrayInteractor, + store: TabsTrayStore, + selectionHolder: SelectionHolder? = null, + itemView: View, + featureName: String, + ) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder, featureName) { + + private val closeButton: AppCompatImageButton = itemView.findViewById(R.id.mozac_browser_tabstray_close) + + override val thumbnailSize: Int + get() = max( + itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_height), + itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_grid_item_thumbnail_width), + ) + + override fun updateSelectedTabIndicator(showAsSelected: Boolean) { + val binding = TabTrayGridItemBinding.bind(itemView) + binding.tabTrayGridItem.background = if (showAsSelected) { + AppCompatResources.getDrawable(itemView.context, R.drawable.tab_tray_grid_item_selected_border) + } else { + null + } + return + } + + override fun bind( + tab: TabSessionState, + isSelected: Boolean, + styling: TabsTrayStyling, + delegate: TabsTray.Delegate, + ) { + super.bind(tab, isSelected, styling, delegate) + + closeButton.increaseTapArea(GRID_ITEM_CLOSE_BUTTON_EXTRA_DPS) + } + + companion object { + const val LAYOUT_ID = R.layout.tab_tray_grid_item + } + } + + /** + * A RecyclerView ViewHolder implementation for "tab" items with list layout. + * + * @param imageLoader [ImageLoader] used to load tab thumbnails. + * @property interactor [TabsTrayInteractor] handling tabs interactions in a tab tray. + * @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that. + * @param selectionHolder [SelectionHolder]<[TabSessionState]> for helping with selecting + * any number of displayed [TabSessionState]s. + * @param itemView [View] that displays a "tab". + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + */ + class ListViewHolder( + imageLoader: ImageLoader, + override val interactor: TabsTrayInteractor, + store: TabsTrayStore, + selectionHolder: SelectionHolder? = null, + itemView: View, + featureName: String, + ) : AbstractBrowserTabViewHolder(itemView, imageLoader, store, selectionHolder, featureName) { + override val thumbnailSize: Int + get() = max( + itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_height), + itemView.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_width), + ) + + override fun updateSelectedTabIndicator(showAsSelected: Boolean) { + val color = if (showAsSelected) { + R.color.fx_mobile_layer_color_accent_opaque + } else { + R.color.fx_mobile_layer_color_1 + } + itemView.setBackgroundColor( + ContextCompat.getColor( + itemView.context, + color, + ), + ) + } + + companion object { + const val LAYOUT_ID = R.layout.tab_tray_item + } + } +} diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/BrowserTabsAdapter.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/BrowserTabsAdapter.kt index fa51c7a7a..fed472dd9 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/BrowserTabsAdapter.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/BrowserTabsAdapter.kt @@ -5,6 +5,8 @@ package net.waterfox.android.tabstray.browser import android.content.Context +import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.lifecycle.LifecycleOwner @@ -15,8 +17,11 @@ import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_DONT_HI import mozilla.components.browser.tabstray.TabsAdapter.Companion.PAYLOAD_HIGHLIGHT_SELECTED_ITEM import mozilla.components.browser.thumbnails.loader.ThumbnailLoader import net.waterfox.android.components.Components +import net.waterfox.android.databinding.TabTrayGridItemBinding +import net.waterfox.android.databinding.TabTrayItemBinding import net.waterfox.android.ext.components import net.waterfox.android.selection.SelectionHolder +import net.waterfox.android.tabstray.TabsTrayInteractor import net.waterfox.android.tabstray.TabsTrayStore import net.waterfox.android.tabstray.browser.compose.ComposeGridViewHolder import net.waterfox.android.tabstray.browser.compose.ComposeListViewHolder @@ -25,23 +30,27 @@ import net.waterfox.android.tabstray.browser.compose.ComposeListViewHolder * A [RecyclerView.Adapter] for browser tabs. * * @param context [Context] used for various platform interactions or accessing [Components] - * @param interactor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @property interactor [TabsTrayInteractor] handling tabs interactions in a tab tray. * @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that. - * @param viewLifecycleOwner [LifecycleOwner] life cycle owner for the view. + * @property featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. + * @property viewLifecycleOwner [LifecycleOwner] life cycle owner for the view. */ class BrowserTabsAdapter( private val context: Context, - val interactor: BrowserTrayInteractor, + val interactor: TabsTrayInteractor, private val store: TabsTrayStore, - internal val viewLifecycleOwner: LifecycleOwner -) : TabsAdapter(interactor) { + override val featureName: String, + internal val viewLifecycleOwner: LifecycleOwner, +) : TabsAdapter(interactor), FeatureNameHolder { /** * The layout types for the tabs. */ enum class ViewType(val layoutRes: Int) { - LIST(ComposeListViewHolder.LAYOUT_ID), - GRID(ComposeGridViewHolder.LAYOUT_ID) + LIST(BrowserTabViewHolder.ListViewHolder.LAYOUT_ID), + COMPOSE_LIST(ComposeListViewHolder.LAYOUT_ID), + GRID(BrowserTabViewHolder.GridViewHolder.LAYOUT_ID), + COMPOSE_GRID(ComposeGridViewHolder.LAYOUT_ID), } /** @@ -52,33 +61,93 @@ class BrowserTabsAdapter( private val selectedItemAdapterBinding = SelectedItemAdapterBinding(store, this) private val imageLoader = ThumbnailLoader(context.components.core.thumbnailStorage) - override fun getItemViewType(position: Int) = - if (context.components.settings.gridTabView) ViewType.GRID.layoutRes - else ViewType.LIST.layoutRes - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = - if (viewType == ViewType.LIST.layoutRes) ComposeListViewHolder( - interactor = interactor, - tabsTrayStore = store, - selectionHolder = selectionHolder, - composeItemView = ComposeView(parent.context), - viewLifecycleOwner = viewLifecycleOwner - ) - else ComposeGridViewHolder( - interactor = interactor, - store = store, - selectionHolder = selectionHolder, - composeItemView = ComposeView(parent.context), - viewLifecycleOwner = viewLifecycleOwner - ) + override fun getItemViewType(position: Int): Int { + return when { + context.components.settings.gridTabView -> { + if (context.components.settings.enableTabsTrayToCompose) { + ViewType.COMPOSE_GRID.layoutRes + } else { + ViewType.GRID.layoutRes + } + } + else -> { + if (context.components.settings.enableTabsTrayToCompose) { + ViewType.COMPOSE_LIST.layoutRes + } else { + ViewType.LIST.layoutRes + } + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SelectableTabViewHolder { + return when (viewType) { + ViewType.COMPOSE_LIST.layoutRes -> + ComposeListViewHolder( + interactor = interactor, + tabsTrayStore = store, + composeItemView = ComposeView(parent.context), + featureName = featureName, + viewLifecycleOwner = viewLifecycleOwner, + ) + ViewType.COMPOSE_GRID.layoutRes -> + ComposeGridViewHolder( + interactor = interactor, + store = store, + composeItemView = ComposeView(parent.context), + featureName = featureName, + viewLifecycleOwner = viewLifecycleOwner, + ) + else -> { + val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) + if (viewType == ViewType.GRID.layoutRes) { + BrowserTabViewHolder.GridViewHolder( + imageLoader, + interactor, + store, + selectionHolder, + view, + featureName, + ) + } else { + BrowserTabViewHolder.ListViewHolder( + imageLoader, + interactor, + store, + selectionHolder, + view, + featureName, + ) + } + } + } + } override fun onBindViewHolder(holder: SelectableTabViewHolder, position: Int) { super.onBindViewHolder(holder, position) + var selectedMaskView: View? = null holder.tab?.let { tab -> + when (getItemViewType(position)) { + ViewType.GRID.layoutRes -> { + val gridBinding = TabTrayGridItemBinding.bind(holder.itemView) + selectedMaskView = gridBinding.checkboxInclude.selectedMask + gridBinding.mozacBrowserTabstrayClose.setOnClickListener { + interactor.onTabClosed(tab, featureName) + } + } + ViewType.LIST.layoutRes -> { + val listBinding = TabTrayItemBinding.bind(holder.itemView) + selectedMaskView = listBinding.checkboxInclude.selectedMask + listBinding.mozacBrowserTabstrayClose.setOnClickListener { + interactor.onTabClosed(tab, featureName) + } + } + } + selectionHolder?.let { holder.showTabIsMultiSelectEnabled( - null, - (it.selectedItems.map { item -> item.id }).contains(tab.id) + selectedMaskView, + (it.selectedItems.map { item -> item.id }).contains(tab.id), ) } } @@ -106,9 +175,20 @@ class BrowserTabsAdapter( } selectionHolder?.let { + var selectedMaskView: View? = null + when (getItemViewType(position)) { + ViewType.GRID.layoutRes -> { + val gridBinding = TabTrayGridItemBinding.bind(holder.itemView) + selectedMaskView = gridBinding.checkboxInclude.selectedMask + } + ViewType.LIST.layoutRes -> { + val listBinding = TabTrayItemBinding.bind(holder.itemView) + selectedMaskView = listBinding.checkboxInclude.selectedMask + } + } holder.showTabIsMultiSelectEnabled( - null, - it.selectedItems.map { item -> item.id }.contains(tab.id) + selectedMaskView, + it.selectedItems.map { item -> item.id }.contains(tab.id), ) } } diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/BrowserTrayInteractor.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/BrowserTrayInteractor.kt deleted file mode 100644 index 7d054e8ee..000000000 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/BrowserTrayInteractor.kt +++ /dev/null @@ -1,210 +0,0 @@ -/* 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 net.waterfox.android.tabstray.browser - -import mozilla.components.browser.state.state.TabSessionState -import mozilla.components.browser.tabstray.TabsTray -import mozilla.components.feature.tabs.TabsUseCases -import mozilla.components.support.base.feature.UserInteractionHandler -import net.waterfox.android.selection.SelectionHolder -import net.waterfox.android.selection.SelectionInteractor -import net.waterfox.android.tabstray.TabsTrayAction -import net.waterfox.android.tabstray.TabsTrayController -import net.waterfox.android.tabstray.TabsTrayInteractor -import net.waterfox.android.tabstray.TabsTrayState.Mode -import net.waterfox.android.tabstray.TabsTrayStore -import net.waterfox.android.tabstray.ext.isSelect - -/** - * For interacting with UI that is specifically for [AbstractBrowserTrayList] and other browser - * tab tray views. - */ -interface BrowserTrayInteractor : SelectionInteractor, UserInteractionHandler, TabsTray.Delegate { - - /** - * Open a tab. - * - * @param tab [TabSessionState] to open in browser. - * @param source app feature from which the [tab] was opened. - */ - fun open(tab: TabSessionState, source: String? = null) - - /** - * Close the tab. - * - * @param tab [TabSessionState] to close. - * @param source app feature from which the [tab] was closed. - */ - fun close(tab: TabSessionState, source: String? = null) - - /** - * TabTray's Floating Action Button clicked. - */ - fun onFabClicked(isPrivate: Boolean) - - /** - * Recently Closed item is clicked. - */ - fun onRecentlyClosedClicked() - - /** - * Indicates Play/Pause item is clicked. - * @param tab [TabSessionState] to close. - */ - fun onMediaClicked(tab: TabSessionState) - - /** - * Handles clicks when multi-selection is enabled. - */ - fun onMultiSelectClicked( - tab: TabSessionState, - holder: SelectionHolder, - source: String? - ) - - /** - * Handles long click events when tab item is clicked. - */ - fun onLongClicked( - tab: TabSessionState, - holder: SelectionHolder - ): Boolean -} - -/** - * A default implementation of [BrowserTrayInteractor]. - */ -@Suppress("TooManyFunctions") -class DefaultBrowserTrayInteractor( - private val store: TabsTrayStore, - private val trayInteractor: TabsTrayInteractor, - private val controller: TabsTrayController, - private val selectTab: TabsUseCases.SelectTabUseCase, -) : BrowserTrayInteractor { - - private val selectTabWrapper by lazy { - SelectTabUseCaseWrapper(selectTab) { - trayInteractor.onBrowserTabSelected() - } - } - - /** - * See [SelectionInteractor.open] - */ - override fun open(item: TabSessionState) { - open(item, null) - } - - /** - * See [BrowserTrayInteractor.open]. - */ - override fun open(tab: TabSessionState, source: String?) { - selectTab(tab, source) - } - - /** - * See [BrowserTrayInteractor.close]. - */ - override fun close(tab: TabSessionState, source: String?) { - closeTab(tab, source) - } - - /** - * See [SelectionInteractor.select] - */ - override fun select(item: TabSessionState) { - store.dispatch(TabsTrayAction.AddSelectTab(item)) - } - - /** - * See [SelectionInteractor.deselect] - */ - override fun deselect(item: TabSessionState) { - store.dispatch(TabsTrayAction.RemoveSelectTab(item)) - } - - /** - * See [UserInteractionHandler.onBackPressed] - * - * TODO move this to the navigation interactor when it lands. - */ - override fun onBackPressed(): Boolean { - if (store.state.mode is Mode.Select) { - store.dispatch(TabsTrayAction.ExitSelectMode) - return true - } - return false - } - - override fun onTabClosed(tab: TabSessionState, source: String?) { - closeTab(tab, source) - } - - override fun onTabSelected(tab: TabSessionState, source: String?) { - selectTab(tab, source) - } - - /** - * See [BrowserTrayInteractor.onFabClicked] - */ - override fun onFabClicked(isPrivate: Boolean) { - controller.handleOpeningNewTab(isPrivate) - } - - /** - * See [BrowserTrayInteractor.onRecentlyClosedClicked] - */ - override fun onRecentlyClosedClicked() { - controller.handleNavigateToRecentlyClosed() - } - - /** - * See [BrowserTrayInteractor.onMultiSelectClicked] - */ - override fun onMediaClicked(tab: TabSessionState) { - controller.handleMediaClicked(tab) - } - - /** - * See [BrowserTrayInteractor.onMultiSelectClicked] - */ - override fun onMultiSelectClicked( - tab: TabSessionState, - holder: SelectionHolder, - source: String? - ) { - val selected = holder.selectedItems - when { - selected.isEmpty() && store.state.mode.isSelect().not() -> { - onTabSelected(tab, source) - } - tab.id in selected.map { it.id } -> deselect(tab) - else -> select(tab) - } - } - - /** - * See [BrowserTrayInteractor.onLongClicked] - */ - override fun onLongClicked( - tab: TabSessionState, - holder: SelectionHolder - ): Boolean { - return if (holder.selectedItems.isEmpty()) { - select(tab) - true - } else { - false - } - } - - private fun selectTab(tab: TabSessionState, source: String? = null) { - selectTabWrapper.invoke(tab.id, source) - } - - private fun closeTab(tab: TabSessionState, source: String? = null) { - trayInteractor.onDeleteTab(tab.id, source) - } -} diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/DraggableItemAnimator.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/DraggableItemAnimator.kt index d23a2e134..2616a7b60 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/DraggableItemAnimator.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/DraggableItemAnimator.kt @@ -12,7 +12,7 @@ class DraggableItemAnimator : DefaultItemAnimator() { override fun animatePersistence( @NonNull viewHolder: RecyclerView.ViewHolder, @NonNull preLayoutInfo: RecyclerView.ItemAnimator.ItemHolderInfo, - @NonNull postLayoutInfo: RecyclerView.ItemAnimator.ItemHolderInfo + @NonNull postLayoutInfo: RecyclerView.ItemAnimator.ItemHolderInfo, ): Boolean { // While being dragged, keep the tab visually in place if (viewHolder is AbstractBrowserTabViewHolder && viewHolder.beingDragged) { diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/FeatureNameHolder.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/FeatureNameHolder.kt new file mode 100644 index 000000000..b852d38a0 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/FeatureNameHolder.kt @@ -0,0 +1,14 @@ +/* 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 net.waterfox.android.tabstray.browser + +/** + * Contains the identifying name of the feature. + * + * This is commonly used for telemetry. + */ +interface FeatureNameHolder { + val featureName: String +} diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabViewHolder.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabViewHolder.kt index 8ec6f0a48..659e0f13c 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabViewHolder.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabViewHolder.kt @@ -5,7 +5,11 @@ package net.waterfox.android.tabstray.browser import android.view.View -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.lifecycle.LifecycleOwner import mozilla.components.lib.state.ext.observeAsComposableState @@ -13,7 +17,10 @@ import net.waterfox.android.R import net.waterfox.android.components.WaterfoxSnackbar import net.waterfox.android.components.components import net.waterfox.android.compose.ComposeViewHolder -import net.waterfox.android.tabstray.* +import net.waterfox.android.tabstray.TabsTrayFragment +import net.waterfox.android.tabstray.TabsTrayState +import net.waterfox.android.tabstray.TabsTrayStore +import net.waterfox.android.tabstray.TrayPagerAdapter import net.waterfox.android.tabstray.inactivetabs.InactiveTabsList /** @@ -22,16 +29,14 @@ import net.waterfox.android.tabstray.inactivetabs.InactiveTabsList * @param composeView [ComposeView] which will be populated with Jetpack Compose UI content. * @param lifecycleOwner [LifecycleOwner] to which this Composable will be tied to. * @param tabsTrayStore [TabsTrayStore] used to listen for changes to [TabsTrayState.inactiveTabs]. - * @param tabsTrayInteractor [TabsTrayInteractor] used to handle deleting all inactive tabs. - * @param inactiveTabsInteractor [InactiveTabsInteractor] used to respond to interactions with the inactive tabs header + * @param interactor [InactiveTabsInteractor] used to respond to interactions with the inactive tabs header * and the auto close dialog. */ -@Suppress("LongParameterList") class InactiveTabViewHolder( composeView: ComposeView, lifecycleOwner: LifecycleOwner, private val tabsTrayStore: TabsTrayStore, - private val inactiveTabsInteractor: InactiveTabsInteractor, + private val interactor: InactiveTabsInteractor, ) : ComposeViewHolder(composeView, lifecycleOwner) { @Composable @@ -49,19 +54,19 @@ class InactiveTabViewHolder( inactiveTabs = inactiveTabs, expanded = expanded, showAutoCloseDialog = showAutoClosePrompt, - onHeaderClick = { inactiveTabsInteractor.onHeaderClicked(!expanded) }, - onDeleteAllButtonClick = inactiveTabsInteractor::onDeleteAllInactiveTabsClicked, + onHeaderClick = { interactor.onInactiveTabsHeaderClicked(!expanded) }, + onDeleteAllButtonClick = interactor::onDeleteAllInactiveTabsClicked, onAutoCloseDismissClick = { - inactiveTabsInteractor.onCloseClicked() + interactor.onAutoCloseDialogCloseButtonClicked() showAutoClosePrompt = !showAutoClosePrompt }, onEnableAutoCloseClick = { - inactiveTabsInteractor.onEnabledAutoCloseClicked() + interactor.onEnableAutoCloseClicked() showAutoClosePrompt = !showAutoClosePrompt showConfirmationSnackbar() }, - onTabClick = inactiveTabsInteractor::onTabClicked, - onTabCloseClick = inactiveTabsInteractor::onTabClosed, + onTabClick = interactor::onInactiveTabClicked, + onTabCloseClick = interactor::onInactiveTabClosed, ) } } @@ -75,7 +80,7 @@ class InactiveTabViewHolder( val snackbar = WaterfoxSnackbar.make( view = composeView, duration = WaterfoxSnackbar.LENGTH_SHORT, - isDisplayedWithBrowserToolbar = true + isDisplayedWithBrowserToolbar = true, ).setText(text) snackbar.view.elevation = TabsTrayFragment.ELEVATION snackbar.show() diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabsAdapter.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabsAdapter.kt index 9ecb9ebb0..617773da8 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabsAdapter.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabsAdapter.kt @@ -16,15 +16,16 @@ import net.waterfox.android.tabstray.TabsTrayStore * * @param lifecycleOwner [LifecycleOwner] to which the Composable will be tied to. * @param tabsTrayStore [TabsTrayStore] used to listen for changes to [TabsTrayState.inactiveTabs]. - * @param inactiveTabsInteractor [InactiveTabsInteractor] used to respond to interactions with the inactive tabs header + * @param interactor [InactiveTabsInteractor] used to respond to interactions with the inactive tabs header * and the auto close dialog. + * @property featureName [String] representing the name of the inactive tabs feature for telemetry reporting. */ -@Suppress("LongParameterList") class InactiveTabsAdapter( private val lifecycleOwner: LifecycleOwner, private val tabsTrayStore: TabsTrayStore, - private val inactiveTabsInteractor: InactiveTabsInteractor -) : RecyclerView.Adapter() { + private val interactor: InactiveTabsInteractor, + override val featureName: String, +) : RecyclerView.Adapter(), FeatureNameHolder { override fun getItemCount(): Int = 1 @@ -33,7 +34,7 @@ class InactiveTabsAdapter( composeView = ComposeView(parent.context), lifecycleOwner = lifecycleOwner, tabsTrayStore = tabsTrayStore, - inactiveTabsInteractor = inactiveTabsInteractor, + interactor = interactor, ) } diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabsController.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabsController.kt index 6cc75efb9..8109ad7cb 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabsController.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabsController.kt @@ -5,13 +5,6 @@ package net.waterfox.android.tabstray.browser import mozilla.components.browser.state.state.TabSessionState -import mozilla.components.browser.state.store.BrowserStore -import mozilla.components.feature.tabs.TabsUseCases -import net.waterfox.android.components.AppStore -import net.waterfox.android.components.appstate.AppAction -import net.waterfox.android.components.appstate.AppAction.UpdateInactiveExpanded -import net.waterfox.android.ext.potentialInactiveTabs -import net.waterfox.android.utils.Settings /** * Contract for how all user interactions with the Inactive Tabs feature are to be handled. @@ -19,87 +12,38 @@ import net.waterfox.android.utils.Settings interface InactiveTabsController { /** - * Opens the given inactive tab. + * Opens the provided inactive tab. + * + * @param tab [TabSessionState] that was clicked. */ - fun openInactiveTab(tab: TabSessionState) + fun handleInactiveTabClicked(tab: TabSessionState) /** - * Closes the given inactive tab. + * Closes the provided inactive tab. + * + * @param tab [TabSessionState] that was clicked. */ - fun closeInactiveTab(tab: TabSessionState) + fun handleCloseInactiveTabClicked(tab: TabSessionState) /** - * Updates the inactive card to be expanded to display all the tabs, or collapsed with only - * the title showing. + * Expands or collapses the inactive tabs section. + * + * @param expanded true when the tap should expand the inactive section. */ - fun updateCardExpansion(isExpanded: Boolean) + fun handleInactiveTabsHeaderClicked(expanded: Boolean) /** - * Dismiss the auto-close dialog. + * Dismisses the inactive tabs auto-close dialog. */ - fun dismissAutoCloseDialog() + fun handleInactiveTabsAutoCloseDialogDismiss() /** - * Enable the auto-close feature with the "after a month" setting. + * Enables the inactive tabs auto-close feature with a default time period. */ - fun enableInactiveTabsAutoClose() + fun handleEnableInactiveTabsAutoCloseClicked() /** - * Delete all inactive tabs. + * Deletes all inactive tabs. */ - fun deleteAllInactiveTabs() -} - -/** - * Default behavior for handling all user interactions with the Inactive Tabs feature. - * - * @param appStore [AppStore] used to dispatch any [AppAction]. - * @param settings [Settings] used to update any user preferences. - * @param browserStore [BrowserStore] used to obtain all inactive tabs. - * @param tabsUseCases [TabsUseCases] used to perform the deletion of all inactive tabs. - * @param showUndoSnackbar Invoked when deleting all inactive tabs. - */ -class DefaultInactiveTabsController( - private val appStore: AppStore, - private val settings: Settings, - private val browserStore: BrowserStore, - private val tabsUseCases: TabsUseCases, - private val showUndoSnackbar: (Boolean) -> Unit, -) : InactiveTabsController { - - override fun openInactiveTab(tab: TabSessionState) { - } - - override fun closeInactiveTab(tab: TabSessionState) { - } - - override fun updateCardExpansion(isExpanded: Boolean) { - appStore.dispatch(UpdateInactiveExpanded(isExpanded)) - } - - override fun dismissAutoCloseDialog() { - markDialogAsShown() - } - - override fun enableInactiveTabsAutoClose() { - markDialogAsShown() - settings.closeTabsAfterOneMonth = true - settings.closeTabsAfterOneWeek = false - settings.closeTabsAfterOneDay = false - settings.manuallyCloseTabs = false - } - - override fun deleteAllInactiveTabs() { - browserStore.state.potentialInactiveTabs.map { it.id }.let { - tabsUseCases.removeTabs(it) - } - showUndoSnackbar(false) - } - - /** - * Marks the dialog as shown and to not be displayed again. - */ - private fun markDialogAsShown() { - settings.hasInactiveTabsAutoCloseDialogBeenDismissed = true - } + fun handleDeleteAllInactiveTabsClicked() } diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabsInteractor.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabsInteractor.kt index fcdc330a3..d698eb692 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabsInteractor.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/InactiveTabsInteractor.kt @@ -9,102 +9,40 @@ import mozilla.components.browser.state.state.TabSessionState /** * Interactor for all things related to inactive tabs in the tabs tray. */ -interface InactiveTabsInteractor : InactiveTabsAutoCloseDialogInteractor { +interface InactiveTabsInteractor { /** - * Invoked when the header is clicked. + * Invoked when the inactive tabs header is clicked. * - * @param activated true when the tap should expand the inactive section. + * @param expanded true when the tap should expand the inactive section. */ - fun onHeaderClicked(activated: Boolean) + fun onInactiveTabsHeaderClicked(expanded: Boolean) /** * Invoked when an inactive tab is clicked. * * @param tab [TabSessionState] that was clicked. */ - fun onTabClicked(tab: TabSessionState) + fun onInactiveTabClicked(tab: TabSessionState) /** * Invoked when an inactive tab is closed. * * @param tab [TabSessionState] that was closed. */ - fun onTabClosed(tab: TabSessionState) + fun onInactiveTabClosed(tab: TabSessionState) /** * Invoked when the user clicks on the delete all inactive tabs button. */ fun onDeleteAllInactiveTabsClicked() -} - -/** - * Interactor for the auto-close dialog in the inactive tabs section. - */ -interface InactiveTabsAutoCloseDialogInteractor { - - /** - * Invoked when the close button is clicked. - */ - fun onCloseClicked() - - /** - * Invoked when the dialog is clicked. - */ - fun onEnabledAutoCloseClicked() -} - -/** - * Interactor to be called for any user interactions with the Inactive Tabs feature. - * - * @param controller [InactiveTabsController] todo. - * @param browserInteractor [BrowserTrayInteractor] used to respond to interactions with specific inactive tabs. - */ -class DefaultInactiveTabsInteractor( - private val controller: InactiveTabsController, - private val browserInteractor: BrowserTrayInteractor, -) : InactiveTabsInteractor { - - /** - * See [InactiveTabsInteractor.onHeaderClicked]. - */ - override fun onHeaderClicked(activated: Boolean) { - controller.updateCardExpansion(activated) - } - - /** - * See [InactiveTabsAutoCloseDialogInteractor.onCloseClicked]. - */ - override fun onCloseClicked() { - controller.dismissAutoCloseDialog() - } - - /** - * See [InactiveTabsAutoCloseDialogInteractor.onEnabledAutoCloseClicked]. - */ - override fun onEnabledAutoCloseClicked() { - controller.enableInactiveTabsAutoClose() - } - - /** - * See [InactiveTabsInteractor.onTabClicked]. - */ - override fun onTabClicked(tab: TabSessionState) { - controller.openInactiveTab(tab) - browserInteractor.onTabSelected(tab) - } /** - * See [InactiveTabsInteractor.onTabClosed]. + * Invoked when the user clicks the close button in the auto close dialog. */ - override fun onTabClosed(tab: TabSessionState) { - controller.closeInactiveTab(tab) - browserInteractor.onTabClosed(tab) - } + fun onAutoCloseDialogCloseButtonClicked() /** - * See [InactiveTabsInteractor.onDeleteAllInactiveTabsClicked]. + * Invoked when the user clicks to enable the inactive tab auto-close feature. */ - override fun onDeleteAllInactiveTabsClicked() { - controller.deleteAllInactiveTabs() - } + fun onEnableAutoCloseClicked() } diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/NormalBrowserTrayList.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/NormalBrowserTrayList.kt index e775120b9..eea0a359f 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/NormalBrowserTrayList.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/NormalBrowserTrayList.kt @@ -14,7 +14,7 @@ import net.waterfox.android.tabstray.ext.browserAdapter class NormalBrowserTrayList @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = 0 + defStyleAttr: Int = 0, ) : AbstractBrowserTrayList(context, attrs, defStyleAttr) { private val concatAdapter by lazy { adapter as ConcatAdapter } @@ -29,7 +29,8 @@ class NormalBrowserTrayList @JvmOverloads constructor( onViewHolderTouched = { it is TabViewHolder && swipeToDelete.isSwipeable }, - onViewHolderDraw = { context.components.settings.gridTabView.not() } + onViewHolderDraw = { context.components.settings.gridTabView.not() }, + featureNameHolder = concatAdapter.browserAdapter, ) } diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/NormalTabsBinding.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/NormalTabsBinding.kt index c06f78d0e..8c6a75ac1 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/NormalTabsBinding.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/NormalTabsBinding.kt @@ -18,7 +18,7 @@ import net.waterfox.android.tabstray.TabsTrayStore class NormalTabsBinding( store: TabsTrayStore, private val browserStore: BrowserStore, - private val tabsTray: TabsTray + private val tabsTray: TabsTray, ) : AbstractBinding(store) { override suspend fun onState(flow: Flow) { flow.distinctUntilChangedBy { it.normalTabs } diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/PrivateBrowserTrayList.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/PrivateBrowserTrayList.kt index 2b6ac78f4..a581ce2bc 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/PrivateBrowserTrayList.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/PrivateBrowserTrayList.kt @@ -13,7 +13,7 @@ import net.waterfox.android.ext.components class PrivateBrowserTrayList @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = 0 + defStyleAttr: Int = 0, ) : AbstractBrowserTrayList(context, attrs, defStyleAttr) { private val privateTabsBinding by lazy { @@ -24,7 +24,8 @@ class PrivateBrowserTrayList @JvmOverloads constructor( TabsTouchHelper( interactionDelegate = (adapter as BrowserTabsAdapter).delegate, onViewHolderTouched = { swipeToDelete.isSwipeable }, - onViewHolderDraw = { context.components.settings.gridTabView.not() } + onViewHolderDraw = { context.components.settings.gridTabView.not() }, + featureNameHolder = (adapter as BrowserTabsAdapter), ) } diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/PrivateTabsBinding.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/PrivateTabsBinding.kt index b3bbee5e8..adcbdf1fc 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/PrivateTabsBinding.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/PrivateTabsBinding.kt @@ -19,7 +19,7 @@ import net.waterfox.android.tabstray.TabsTrayStore class PrivateTabsBinding( store: TabsTrayStore, private val browserStore: BrowserStore, - private val tray: TabsTray + private val tray: TabsTray, ) : AbstractBinding(store) { override suspend fun onState(flow: Flow) { flow.map { it.privateTabs } diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/SelectedItemAdapterBinding.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/SelectedItemAdapterBinding.kt index da1f36607..a934e05fe 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/SelectedItemAdapterBinding.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/SelectedItemAdapterBinding.kt @@ -22,7 +22,7 @@ import net.waterfox.android.tabstray.TabsTrayStore @OptIn(ExperimentalCoroutinesApi::class) class SelectedItemAdapterBinding( store: TabsTrayStore, - val adapter: RecyclerView.Adapter + val adapter: RecyclerView.Adapter, ) : AbstractBinding(store) { override suspend fun onState(flow: Flow) { diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionBannerBinding.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionBannerBinding.kt index ede18c29e..53845b5ca 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionBannerBinding.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionBannerBinding.kt @@ -17,7 +17,6 @@ import mozilla.components.lib.state.helpers.AbstractBinding import net.waterfox.android.R import net.waterfox.android.databinding.ComponentTabstray2Binding import net.waterfox.android.databinding.TabstrayMultiselectItemsBinding -import net.waterfox.android.tabstray.NavigationInteractor import net.waterfox.android.tabstray.TabsTrayAction.ExitSelectMode import net.waterfox.android.tabstray.TabsTrayInteractor import net.waterfox.android.tabstray.TabsTrayState @@ -29,13 +28,13 @@ import net.waterfox.android.tabstray.ext.showWithTheme /** * A binding that shows/hides the multi-select banner of the selected count of tabs. * - * @property context An Android context. - * @property store The TabsTrayStore instance. - * @property navInteractor An instance of [NavigationInteractor] for navigating on menu clicks. - * @property tabsTrayInteractor An instance of [TabsTrayInteractor] for handling deletion. - * @property backgroundView The background view that we want to alter when changing [Mode]. - * @property showOnSelectViews A variable list of views that will be made visible when in select mode. - * @property showOnNormalViews A variable list of views that will be made visible when in normal mode. + * @param context An Android context. + * @param binding The binding used to display the view. + * @param store [TabsTrayStore] used to listen for changes to [TabsTrayState] and dispatch actions. + * @param interactor [TabsTrayInteractor] for responding to user actions. + * @param backgroundView The background view that we want to alter when changing [Mode]. + * @param showOnSelectViews A variable list of views that will be made visible when in select mode. + * @param showOnNormalViews A variable list of views that will be made visible when in normal mode. */ @OptIn(ExperimentalCoroutinesApi::class) @Suppress("LongParameterList") @@ -43,11 +42,10 @@ class SelectionBannerBinding( private val context: Context, private val binding: ComponentTabstray2Binding, private val store: TabsTrayStore, - private val navInteractor: NavigationInteractor, - private val tabsTrayInteractor: TabsTrayInteractor, + private val interactor: TabsTrayInteractor, private val backgroundView: View, private val showOnSelectViews: VisibilityModifier, - private val showOnNormalViews: VisibilityModifier + private val showOnNormalViews: VisibilityModifier, ) : AbstractBinding(store) { /** @@ -89,11 +87,13 @@ class SelectionBannerBinding( val tabsTrayMultiselectItemsBinding = TabstrayMultiselectItemsBinding.bind(binding.root) tabsTrayMultiselectItemsBinding.shareMultiSelect.setOnClickListener { - navInteractor.onShareTabs(store.state.mode.selectedTabs) + interactor.onShareSelectedTabs() } tabsTrayMultiselectItemsBinding.collectMultiSelect.setOnClickListener { - navInteractor.onSaveToCollections(store.state.mode.selectedTabs) + if (store.state.mode.selectedTabs.isNotEmpty()) { + interactor.onAddSelectedTabsToCollectionClicked() + } } binding.exitMultiSelect.setOnClickListener { @@ -102,10 +102,8 @@ class SelectionBannerBinding( tabsTrayMultiselectItemsBinding.menuMultiSelect.setOnClickListener { anchor -> val menu = SelectionMenuIntegration( - context, - store, - navInteractor, - tabsTrayInteractor + context = context, + interactor = interactor, ).build() menu.showWithTheme(anchor) diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionHandleBinding.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionHandleBinding.kt index 5943dd845..693b5946a 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionHandleBinding.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionHandleBinding.kt @@ -27,14 +27,14 @@ private const val NORMAL_HANDLE_PERCENT_WIDTH = 0.1F * between [Mode]. * * @param store The TabsTrayStore instance. - * @property handle The "handle" of the Tabs Tray that is used to drag the tray open/close. - * @property containerLayout The [ConstraintLayout] that contains the "handle". + * @param handle The "handle" of the Tabs Tray that is used to drag the tray open/close. + * @param containerLayout The [ConstraintLayout] that contains the "handle". */ @OptIn(ExperimentalCoroutinesApi::class) class SelectionHandleBinding( store: TabsTrayStore, private val handle: View, - private val containerLayout: ConstraintLayout + private val containerLayout: ConstraintLayout, ) : AbstractBinding(store) { private var isPreviousModeSelect = false @@ -65,14 +65,14 @@ class SelectionHandleBinding( R.dimen.tab_tray_multiselect_handle_height } else { R.dimen.bottom_sheet_handle_height - } + }, ) topMargin = handle.resources.getDimensionPixelSize( if (multiselect) { R.dimen.tab_tray_multiselect_handle_top_margin } else { R.dimen.bottom_sheet_handle_top_margin - } + }, ) } } @@ -92,7 +92,7 @@ class SelectionHandleBinding( private fun updateWidthPercent( container: ConstraintLayout, handle: View, - multiselect: Boolean + multiselect: Boolean, ) { val widthPercent = if (multiselect) 1F else NORMAL_HANDLE_PERCENT_WIDTH container.run { diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionMenu.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionMenu.kt index a5a1673b9..ba1f2e998 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionMenu.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionMenu.kt @@ -13,7 +13,7 @@ import net.waterfox.android.ext.components class SelectionMenu( private val context: Context, - private val onItemTapped: (Item) -> Unit = {} + private val onItemTapped: (Item) -> Unit = {}, ) { sealed class Item { object BookmarkTabs : Item() @@ -27,27 +27,27 @@ class SelectionMenu( listOf( SimpleBrowserMenuItem( context.getString(R.string.tab_tray_multiselect_menu_item_bookmark), - textColorResource = R.color.fx_mobile_text_color_primary + textColorResource = R.color.fx_mobile_text_color_primary, ) { onItemTapped.invoke(Item.BookmarkTabs) }, SimpleBrowserMenuItem( context.getString(R.string.tab_tray_multiselect_menu_item_close), - textColorResource = R.color.fx_mobile_text_color_primary + textColorResource = R.color.fx_mobile_text_color_primary, ) { onItemTapped.invoke(Item.DeleteTabs) }, // This item is only visible for debugging. SimpleBrowserMenuItem( context.getString(R.string.inactive_tabs_menu_item), - textColorResource = R.color.fx_mobile_text_color_primary + textColorResource = R.color.fx_mobile_text_color_primary, ) { onItemTapped.invoke(Item.MakeInactive) }.apply { // We only want this menu option visible when in debug mode for testing. visible = { Config.channel.isDebug || context.components.settings.showSecretDebugMenuThisSession } - } + }, ) } } diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionMenuIntegration.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionMenuIntegration.kt index 24f1ef420..ea695f6bc 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionMenuIntegration.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/SelectionMenuIntegration.kt @@ -7,17 +7,11 @@ package net.waterfox.android.tabstray.browser import android.content.Context import androidx.annotation.VisibleForTesting import mozilla.components.browser.menu.BrowserMenuBuilder -import net.waterfox.android.tabstray.NavigationInteractor -import net.waterfox.android.tabstray.TabsTrayAction import net.waterfox.android.tabstray.TabsTrayInteractor -import net.waterfox.android.tabstray.TabsTrayStore -import net.waterfox.android.utils.Do class SelectionMenuIntegration( private val context: Context, - private val store: TabsTrayStore, - private val navInteractor: NavigationInteractor, - private val trayInteractor: TabsTrayInteractor + private val interactor: TabsTrayInteractor, ) { private val menu by lazy { SelectionMenu(context, ::handleMenuClicked) @@ -30,17 +24,16 @@ class SelectionMenuIntegration( @VisibleForTesting internal fun handleMenuClicked(item: SelectionMenu.Item) { - Do exhaustive when (item) { + when (item) { is SelectionMenu.Item.BookmarkTabs -> { - navInteractor.onSaveToBookmarks(store.state.mode.selectedTabs) + interactor.onBookmarkSelectedTabsClicked() } is SelectionMenu.Item.DeleteTabs -> { - trayInteractor.onDeleteTabs(store.state.mode.selectedTabs) + interactor.onDeleteSelectedTabsClicked() } is SelectionMenu.Item.MakeInactive -> { - trayInteractor.onInactiveDebugClicked(store.state.mode.selectedTabs) + interactor.onForceSelectedTabsAsInactiveClicked() } } - store.dispatch(TabsTrayAction.ExitSelectMode) } } diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/SwipeToDeleteBinding.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/SwipeToDeleteBinding.kt index f6232091e..c9753f76b 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/SwipeToDeleteBinding.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/SwipeToDeleteBinding.kt @@ -6,9 +6,9 @@ package net.waterfox.android.tabstray.browser import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import mozilla.components.lib.state.helpers.AbstractBinding -import kotlinx.coroutines.flow.distinctUntilChanged import net.waterfox.android.tabstray.TabsTrayState import net.waterfox.android.tabstray.TabsTrayStore @@ -17,7 +17,7 @@ import net.waterfox.android.tabstray.TabsTrayStore */ @OptIn(ExperimentalCoroutinesApi::class) class SwipeToDeleteBinding( - store: TabsTrayStore + store: TabsTrayStore, ) : AbstractBinding(store) { var isSwipeable = false private set diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/TabSorter.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/TabSorter.kt index 6b972bd43..d9f81db6e 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/TabSorter.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/TabSorter.kt @@ -19,7 +19,7 @@ import net.waterfox.android.utils.Settings */ class TabSorter( private val settings: Settings, - private val tabsTrayStore: TabsTrayStore? = null + private val tabsTrayStore: TabsTrayStore? = null, ) : TabsTray { override fun updateTabs(tabs: List, tabPartition: TabPartition?, selectedTabId: String?) { diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/TabsAdapter.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/TabsAdapter.kt index d96a40e84..3168ed821 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/TabsAdapter.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/TabsAdapter.kt @@ -16,7 +16,7 @@ import mozilla.components.browser.tabstray.TabsTrayStyling /** * RecyclerView adapter implementation to display a list/grid of tabs. * - * The previous tabs adapter was very restrictive and required Waterfox to jump through + * The previous tabs adapter was very restrictive and required Fenix to jump through * may hoops to access and update certain methods. An abstract adapter is easier to manage * for Android UI APIs. * diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/TabsTouchHelper.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/TabsTouchHelper.kt index f509df5ae..d71a5bb57 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/TabsTouchHelper.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/TabsTouchHelper.kt @@ -33,30 +33,40 @@ typealias OnViewHolderToDraw = (RecyclerView.ViewHolder) -> Boolean /** * An [ItemTouchHelper] for handling tab swiping to delete. * + * @param interactionDelegate [TabsTray.Delegate] for handling all user interactions. * @param onViewHolderTouched See [OnViewHolderTouched]. + * @param onViewHolderDraw See [OnViewHolderToDraw]. + * @param featureNameHolder Contains the identifying name of the feature. + * @param delegate The Callback which controls the behavior of this touch helper. */ class TabsTouchHelper( interactionDelegate: TabsTray.Delegate, onViewHolderTouched: OnViewHolderTouched = { true }, onViewHolderDraw: OnViewHolderToDraw = { true }, - delegate: Callback = TouchCallback(interactionDelegate, onViewHolderTouched, onViewHolderDraw), + featureNameHolder: FeatureNameHolder, + delegate: Callback = TouchCallback(interactionDelegate, onViewHolderTouched, onViewHolderDraw, featureNameHolder), ) : ItemTouchHelper(delegate) /** * An [ItemTouchHelper.Callback] for drawing custom layouts on [RecyclerView.ViewHolder] interactions. * - * @param onViewHolderTouched invoked when a tab is about to be swiped. See [OnViewHolderTouched]. + * @param delegate [TabsTray.Delegate] for handling all user interactions. + * @param onViewHolderTouched Invoked when a tab is about to be swiped. See [OnViewHolderTouched]. + * @param onViewHolderDraw Invoked when a tab is drawn. See [OnViewHolderToDraw]. + * @param featureNameHolder Contains the identifying name of the feature. + * @param onRemove A callback invoked when a tab is removed. */ class TouchCallback( delegate: TabsTray.Delegate, private val onViewHolderTouched: OnViewHolderTouched, private val onViewHolderDraw: OnViewHolderToDraw, - onRemove: (TabSessionState) -> Unit = { delegate.onTabClosed(it) } + featureNameHolder: FeatureNameHolder, + onRemove: (TabSessionState) -> Unit = { delegate.onTabClosed(it, featureNameHolder.featureName) }, ) : TabTouchCallback(onRemove) { override fun getMovementFlags( recyclerView: RecyclerView, - viewHolder: RecyclerView.ViewHolder + viewHolder: RecyclerView.ViewHolder, ): Int { if (!onViewHolderTouched.invoke(viewHolder)) { return ItemTouchHelper.Callback.makeFlag(ACTION_STATE_IDLE, 0) @@ -72,7 +82,7 @@ class TouchCallback( dX: Float, dY: Float, actionState: Int, - isCurrentlyActive: Boolean + isCurrentlyActive: Boolean, ) { super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) @@ -82,11 +92,11 @@ class TouchCallback( val icon = recyclerView.context.getDrawableWithTint( R.drawable.ic_delete, - recyclerView.context.getColorFromAttr(R.attr.textWarning) + recyclerView.context.getColorFromAttr(R.attr.textWarning), )!! val background = AppCompatResources.getDrawable( recyclerView.context, - R.drawable.swipe_delete_background + R.drawable.swipe_delete_background, )!! val itemView = viewHolder.itemView val iconLeft: Int @@ -104,9 +114,10 @@ class TouchCallback( iconLeft = itemView.left + margin iconRight = itemView.left + margin + iconWidth background.setBounds( - itemView.left, itemView.top, + itemView.left, + itemView.top, (itemView.left + dX).toInt() + BACKGROUND_CORNER_OFFSET, - itemView.bottom + itemView.bottom, ) icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) draw(background, icon, c) @@ -116,7 +127,9 @@ class TouchCallback( iconRight = itemView.right - margin background.setBounds( (itemView.right + dX).toInt() - BACKGROUND_CORNER_OFFSET, - itemView.top, itemView.right, itemView.bottom + itemView.top, + itemView.right, + itemView.bottom, ) icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) draw(background, icon, c) @@ -131,7 +144,7 @@ class TouchCallback( private fun draw( background: Drawable, icon: Drawable, - c: Canvas + c: Canvas, ) { background.draw(c) icon.draw(c) diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/TabsTrayFabController.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/TabsTrayFabController.kt new file mode 100644 index 000000000..cdd3c73a6 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/TabsTrayFabController.kt @@ -0,0 +1,25 @@ +/* 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 net.waterfox.android.tabstray.browser + +/** + * Contract for handling all user interactions with the Tabs Tray floating action button. + */ +interface TabsTrayFabController { + /** + * Opens a new normal tab. + */ + fun handleNormalTabsFabClick() + + /** + * Opens a new private tab. + */ + fun handlePrivateTabsFabClick() + + /** + * Starts a re-sync of synced content if a sync isn't already underway. + */ + fun handleSyncedTabsFabClick() +} diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/TabsTrayFabInteractor.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/TabsTrayFabInteractor.kt new file mode 100644 index 000000000..f208e7eb2 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/TabsTrayFabInteractor.kt @@ -0,0 +1,27 @@ +/* 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 net.waterfox.android.tabstray.browser + +import net.waterfox.android.tabstray.Page + +/** + * Interactor for all things related to the floating action button in the tabs tray. + */ +interface TabsTrayFabInteractor { + /** + * Invoked when the fab is clicked in [Page.NormalTabs]. + */ + fun onNormalTabsFabClicked() + + /** + * Invoked when the fab is clicked in [Page.PrivateTabs]. + */ + fun onPrivateTabsFabClicked() + + /** + * Invoked when the fab is clicked in [Page.SyncedTabs]. + */ + fun onSyncedTabsFabClicked() +} diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/UseCases.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/UseCases.kt deleted file mode 100644 index 8bdec1a43..000000000 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/UseCases.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* 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 net.waterfox.android.tabstray.browser - -import mozilla.components.feature.tabs.TabsUseCases - -class SelectTabUseCaseWrapper( - private val selectTab: TabsUseCases.SelectTabUseCase, - private val onSelect: (String) -> Unit -) : TabsUseCases.SelectTabUseCase { - operator fun invoke(tabId: String, source: String? = null) { - selectTab(tabId) - onSelect(tabId) - } - - override fun invoke(tabId: String) { - invoke(tabId, null) - } -} - -class RemoveTabUseCaseWrapper( - private val onRemove: (String) -> Unit, -) : TabsUseCases.RemoveTabUseCase { - operator fun invoke(tabId: String, source: String? = null) { - onRemove(tabId) - } - - override fun invoke(tabId: String) { - invoke(tabId, null) - } -} diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ComposeAbstractTabViewHolder.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ComposeAbstractTabViewHolder.kt index da91f5be4..201834642 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ComposeAbstractTabViewHolder.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ComposeAbstractTabViewHolder.kt @@ -25,7 +25,7 @@ import net.waterfox.android.theme.Theme */ abstract class ComposeAbstractTabViewHolder( private val composeView: ComposeView, - private val viewLifecycleOwner: LifecycleOwner + private val viewLifecycleOwner: LifecycleOwner, ) : SelectableTabViewHolder(composeView) { /** diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ComposeGridViewHolder.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ComposeGridViewHolder.kt index 1fe35dbc6..18cb442e0 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ComposeGridViewHolder.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ComposeGridViewHolder.kt @@ -19,27 +19,25 @@ import mozilla.components.lib.state.ext.observeAsComposableState import net.waterfox.android.R import net.waterfox.android.components.components import net.waterfox.android.compose.tabstray.TabGridItem -import net.waterfox.android.selection.SelectionHolder +import net.waterfox.android.tabstray.TabsTrayInteractor import net.waterfox.android.tabstray.TabsTrayState import net.waterfox.android.tabstray.TabsTrayStore -import net.waterfox.android.tabstray.browser.BrowserTrayInteractor import kotlin.math.max /** * A Compose ViewHolder implementation for "tab" items with grid layout. * - * @param interactor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @param interactor [TabsTrayInteractor] handling tabs interactions in a tab tray. * @param store [TabsTrayStore] containing the complete state of tabs tray and methods to update that. - * @param selectionHolder [SelectionHolder]<[TabSessionState]> for helping with selecting - * any number of displayed [TabSessionState]s. * @param composeItemView that displays a "tab". + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. * @param viewLifecycleOwner [LifecycleOwner] to which this Composable will be tied to. */ class ComposeGridViewHolder( - private val interactor: BrowserTrayInteractor, + private val interactor: TabsTrayInteractor, private val store: TabsTrayStore, - private val selectionHolder: SelectionHolder? = null, composeItemView: ComposeView, + private val featureName: String, viewLifecycleOwner: LifecycleOwner, ) : ComposeAbstractTabViewHolder(composeItemView, viewLifecycleOwner) { @@ -51,7 +49,7 @@ class ComposeGridViewHolder( tab: TabSessionState, isSelected: Boolean, styling: TabsTrayStyling, - delegate: TabsTray.Delegate + delegate: TabsTray.Delegate, ) { this.tab = tab isSelectedTabState.value = isSelected @@ -67,21 +65,11 @@ class ComposeGridViewHolder( } private fun onCloseClicked(tab: TabSessionState) { - interactor.onTabClosed(tab) + interactor.onTabClosed(tab, featureName) } private fun onClick(tab: TabSessionState) { - val holder = selectionHolder - if (holder != null) { - interactor.onMultiSelectClicked(tab, holder, null) - } else { - interactor.onTabSelected(tab) - } - } - - private fun onLongClick(tab: TabSessionState) { - val holder = selectionHolder ?: return - interactor.onLongClicked(tab, holder) + interactor.onTabSelected(tab, featureName) } @Composable @@ -107,7 +95,6 @@ class ComposeGridViewHolder( onCloseClick = ::onCloseClicked, onMediaClick = interactor::onMediaClicked, onClick = ::onClick, - onLongClick = ::onLongClick, ) } diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ComposeListViewHolder.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ComposeListViewHolder.kt index 7ba250660..52480380b 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ComposeListViewHolder.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ComposeListViewHolder.kt @@ -9,37 +9,32 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.flow.MutableStateFlow import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.tabstray.TabsTray import mozilla.components.browser.tabstray.TabsTrayStyling import mozilla.components.lib.state.ext.observeAsComposableState -import net.waterfox.android.R import net.waterfox.android.components.components import net.waterfox.android.compose.tabstray.TabListItem -import net.waterfox.android.selection.SelectionHolder +import net.waterfox.android.tabstray.TabsTrayInteractor import net.waterfox.android.tabstray.TabsTrayState import net.waterfox.android.tabstray.TabsTrayStore -import net.waterfox.android.tabstray.browser.BrowserTrayInteractor -import kotlin.math.max /** * A Compose ViewHolder implementation for "tab" items with list layout. * - * @param interactor [BrowserTrayInteractor] handling tabs interactions in a tab tray. + * @param interactor [TabsTrayInteractor] handling tabs interactions in a tab tray. * @param tabsTrayStore [TabsTrayStore] containing the complete state of tabs tray and methods to update that. - * @param selectionHolder [SelectionHolder]<[TabSessionState]> for helping with selecting - * any number of displayed [TabSessionState]s. * @param composeItemView that displays a "tab". + * @param featureName [String] representing the name of the feature displaying tabs. Used in telemetry reporting. * @param viewLifecycleOwner [LifecycleOwner] to which this Composable will be tied to. */ class ComposeListViewHolder( - private val interactor: BrowserTrayInteractor, + private val interactor: TabsTrayInteractor, private val tabsTrayStore: TabsTrayStore, - private val selectionHolder: SelectionHolder? = null, composeItemView: ComposeView, + private val featureName: String, viewLifecycleOwner: LifecycleOwner, ) : ComposeAbstractTabViewHolder(composeItemView, viewLifecycleOwner) { @@ -53,7 +48,7 @@ class ComposeListViewHolder( tab: TabSessionState, isSelected: Boolean, styling: TabsTrayStyling, - delegate: TabsTray.Delegate + delegate: TabsTray.Delegate, ) { this.tab = tab this.delegate = delegate @@ -70,39 +65,25 @@ class ComposeListViewHolder( } private fun onCloseClicked(tab: TabSessionState) { - delegate?.onTabClosed(tab) + delegate?.onTabClosed(tab, featureName) } private fun onClick(tab: TabSessionState) { - val holder = selectionHolder - if (holder != null) { - interactor.onMultiSelectClicked(tab, holder, null) - } else { - interactor.onTabSelected(tab) - } - } - - private fun onLongClick(tab: TabSessionState) { - val holder = selectionHolder ?: return - interactor.onLongClicked(tab, holder) + interactor.onTabSelected(tab, featureName) } @Composable override fun Content(tab: TabSessionState) { - val multiSelectionEnabled = tabsTrayStore.observeAsComposableState { state -> + val multiSelectionEnabled = tabsTrayStore.observeAsComposableState { + state -> state.mode is TabsTrayState.Mode.Select }.value ?: false val isSelectedTabState by isSelectedTab.collectAsState() val multiSelectionSelected by isMultiSelectionSelected.collectAsState() - val tabThumbnailSize = max( - LocalContext.current.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_height), - LocalContext.current.resources.getDimensionPixelSize(R.dimen.tab_tray_list_item_thumbnail_width), - ) - TabListItem( tab = tab, - thumbnailSize = tabThumbnailSize, + thumbnailSize = 108, storage = components.core.thumbnailStorage, isSelected = isSelectedTabState, multiSelectionEnabled = multiSelectionEnabled, @@ -110,7 +91,6 @@ class ComposeListViewHolder( onCloseClick = ::onCloseClicked, onMediaClick = interactor::onMediaClicked, onClick = ::onClick, - onLongClick = ::onLongClick, ) } diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ReorderableGrid.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ReorderableGrid.kt new file mode 100644 index 000000000..185752ca6 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ReorderableGrid.kt @@ -0,0 +1,302 @@ +/* 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 net.waterfox.android.tabstray.browser.compose + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.toOffset +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Remember the reordering state for reordering grid items. + * + * @param gridState State of the grid. + * @param onMove Callback to be invoked when switching between two items. + * @param ignoredItems List of keys for non-draggable items. + * @param onLongPress Optional callback to be invoked when long pressing an item. + * @param onExitLongPress Optional callback to be invoked when the item is dragged after long press. + */ +@Composable +fun createGridReorderState( + gridState: LazyGridState, + onMove: (LazyGridItemInfo, LazyGridItemInfo) -> Unit, + ignoredItems: List, + onLongPress: (LazyGridItemInfo) -> Unit = {}, + onExitLongPress: () -> Unit = {}, +): GridReorderState { + val scope = rememberCoroutineScope() + val touchSlop = LocalViewConfiguration.current.touchSlop + val hapticFeedback = LocalHapticFeedback.current + val state = remember(gridState) { + GridReorderState( + gridState = gridState, + onMove = onMove, + scope = scope, + touchSlop = touchSlop, + ignoredItems = ignoredItems, + onLongPress = onLongPress, + hapticFeedback = hapticFeedback, + onExitLongPress = onExitLongPress, + ) + } + return state +} + +/** + * Class containing details about the current state of dragging in grid. + * + * @param gridState State of the grid. + * @param scope [CoroutineScope] used for scrolling to the target item. + * @param hapticFeedback [HapticFeedback] used for performing haptic feedback on item long press. + * @param touchSlop Distance in pixels the user can wander until we consider they started dragging. + * @param onMove Callback to be invoked when switching between two items. + * @param onLongPress Optional callback to be invoked when long pressing an item. + * @param onExitLongPress Optional callback to be invoked when the item is dragged after long press. + * @param ignoredItems List of keys for non-draggable items. + */ +class GridReorderState internal constructor( + private val gridState: LazyGridState, + private val scope: CoroutineScope, + private val hapticFeedback: HapticFeedback, + private val touchSlop: Float, + private val onMove: (LazyGridItemInfo, LazyGridItemInfo) -> Unit, + private val onLongPress: (LazyGridItemInfo) -> Unit = {}, + private val onExitLongPress: () -> Unit = {}, + private val ignoredItems: List = emptyList(), +) { + internal var draggingItemKey by mutableStateOf(null) + private set + + private var draggingItemCumulatedOffset by mutableStateOf(Offset.Zero) + private var draggingItemInitialOffset by mutableStateOf(Offset.Zero) + internal var moved by mutableStateOf(false) + private val draggingItemOffset: Offset + get() = draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemCumulatedOffset - item.offset.toOffset() + } ?: Offset.Zero + + internal fun computeItemOffset(index: Int): Offset { + val itemAtIndex = gridState.layoutInfo.visibleItemsInfo.firstOrNull { info -> info.index == index } + ?: return Offset.Zero + return draggingItemInitialOffset + draggingItemCumulatedOffset - itemAtIndex.offset.toOffset() + } + + private val draggingItemLayoutInfo: LazyGridItemInfo? + get() = gridState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == draggingItemKey } + + internal var previousKeyOfDraggedItem by mutableStateOf(null) + private set + internal var previousItemOffset = Animatable(Offset.Zero, Offset.VectorConverter) + private set + + internal fun onTouchSlopPassed(offset: Offset, shouldLongPress: Boolean) { + gridState.findItem(offset)?.also { + draggingItemKey = it.key + if (shouldLongPress) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onLongPress(it) + } + draggingItemInitialOffset = it.offset.toOffset() + moved = !shouldLongPress + } + } + + internal fun onDragInterrupted() { + if (draggingItemKey != null) { + previousKeyOfDraggedItem = draggingItemKey + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + Offset.Zero, + spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Offset.VisibilityThreshold, + ), + ) + previousKeyOfDraggedItem = null + } + } + draggingItemCumulatedOffset = Offset.Zero + draggingItemKey = null + draggingItemInitialOffset = Offset.Zero + } + + internal fun onDrag(offset: Offset) { + draggingItemCumulatedOffset += offset + + if (draggingItemLayoutInfo == null) { + moved = false + } + val draggingItem = draggingItemLayoutInfo ?: return + + if (!moved && draggingItemCumulatedOffset.getDistance() > touchSlop) { + onExitLongPress() + } + val startOffset = draggingItem.offset.toOffset() + draggingItemOffset + val endOffset = Offset( + startOffset.x + draggingItem.size.toSize().width, + startOffset.y + draggingItem.size.toSize().height, + ) + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = gridState.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.x.toInt() in item.offset.x..item.endOffset.x && + middleOffset.y.toInt() in item.offset.y..item.endOffset.y && + draggingItemKey != item.key + } + if (targetItem != null && targetItem.key !in ignoredItems) { + if (draggingItem.index == gridState.firstVisibleItemIndex) { + scope.launch { + gridState.scrollBy(-draggingItem.size.height.toFloat()) + } + } + onMove.invoke(draggingItem, targetItem) + } else { + val overscroll = when { + draggingItemCumulatedOffset.y > 0 -> + (endOffset.y - gridState.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + + draggingItemCumulatedOffset.y < 0 -> + (startOffset.y - gridState.layoutInfo.viewportStartOffset).coerceAtMost(0f) + + else -> 0f + } + if (overscroll != 0f) { + scope.launch { + gridState.scrollBy(overscroll) + } + } + } + } +} + +/** + * Container for draggable grid item. + * + * @param state State of the lazy grid. + * @param key Key of the item to be displayed. + * @param position Position in the grid of the item to be displayed. + * @param content Content of the item to be displayed. + */ +@ExperimentalFoundationApi +@Composable +fun LazyGridItemScope.DragItemContainer( + state: GridReorderState, + key: Any, + position: Int, + content: @Composable () -> Unit, +) { + val modifier = when (key) { + state.draggingItemKey -> { + Modifier + .zIndex(1f) + .graphicsLayer { + translationX = state.computeItemOffset(position).x + translationY = state.computeItemOffset(position).y + } + } + + state.previousKeyOfDraggedItem -> { + Modifier + .zIndex(1f) + .graphicsLayer { + translationX = state.previousItemOffset.value.x + translationY = state.previousItemOffset.value.y + } + } + + else -> { + Modifier + .zIndex(0f) + .animateItemPlacement(tween()) + } + } + + Box(modifier = modifier, propagateMinConstraints = true) { + content() + } +} + +/** + * Calculate the offset of an item taking its width and height into account. + */ +private val LazyGridItemInfo.endOffset: IntOffset + get() = IntOffset(offset.x + size.width, offset.y + size.height) + +/** + * Find item based on position on screen. + * + * @param offset Position on screen used to find the item. + */ +private fun LazyGridState.findItem(offset: Offset) = + layoutInfo.visibleItemsInfo.firstOrNull { item -> + offset.x.toInt() in item.offset.x..item.endOffset.x && offset.y.toInt() in item.offset.y..item.endOffset.y + } + +/** + * Detects press, long press and drag gestures. + * + * @param gridState State of the grid. + * @param reorderState Grid reordering state used for dragging callbacks. + * @param shouldLongPressToDrag Whether or not an item should be long pressed to start the dragging gesture. + */ +fun Modifier.detectGridPressAndDragGestures( + gridState: LazyGridState, + reorderState: GridReorderState, + shouldLongPressToDrag: Boolean, +): Modifier = pointerInput(gridState, shouldLongPressToDrag) { + if (shouldLongPressToDrag) { + detectDragGesturesAfterLongPress( + onDragStart = { offset -> reorderState.onTouchSlopPassed(offset, true) }, + onDrag = { change, dragAmount -> + change.consume() + reorderState.onDrag(dragAmount) + }, + onDragEnd = reorderState::onDragInterrupted, + onDragCancel = reorderState::onDragInterrupted, + ) + } else { + detectDragGestures( + onDragStart = { offset -> reorderState.onTouchSlopPassed(offset, false) }, + onDrag = { change, dragAmount -> + change.consume() + reorderState.onDrag(dragAmount) + }, + onDragEnd = reorderState::onDragInterrupted, + onDragCancel = reorderState::onDragInterrupted, + ) + } +} diff --git a/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ReorderableList.kt b/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ReorderableList.kt new file mode 100644 index 000000000..feeac4577 --- /dev/null +++ b/app/src/main/java/net/waterfox/android/tabstray/browser/compose/ReorderableList.kt @@ -0,0 +1,285 @@ +/* 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 net.waterfox.android.tabstray.browser.compose + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.zIndex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import kotlin.math.abs + +/** + * Remember the reordering state for reordering list items. + * + * @param listState State of the list. + * @param onMove Callback to be invoked when switching between two items. + * @param ignoredItems List of keys for non-draggable items. + * @param onLongPress Callback to be invoked when long pressing an item. + * @param onExitLongPress Callback to be invoked when the item is dragged after long press. + */ +@Composable +fun createListReorderState( + listState: LazyListState, + onMove: (LazyListItemInfo, LazyListItemInfo) -> Unit, + ignoredItems: List, + onLongPress: (LazyListItemInfo) -> Unit = {}, + onExitLongPress: () -> Unit = {}, +): ListReorderState { + val scope = rememberCoroutineScope() + val touchSlop = LocalViewConfiguration.current.touchSlop + val hapticFeedback = LocalHapticFeedback.current + val state = remember(listState) { + ListReorderState( + listState = listState, + onMove = onMove, + scope = scope, + touchSlop = touchSlop, + hapticFeedback = hapticFeedback, + ignoredItems = ignoredItems, + onLongPress = onLongPress, + onExitLongPress = onExitLongPress, + ) + } + return state +} + +/** + * Class containing details about the current state of dragging in list. + * + * @param listState State of the list. + * @param scope [CoroutineScope] used for scrolling to the target item. + * @param hapticFeedback [HapticFeedback] used for performing haptic feedback on item long press. + * @param touchSlop Distance in pixels the user can wander until we consider they started dragging. + * @param onMove Callback to be invoked when switching between two items. + * @param ignoredItems List of keys for non-draggable items. + * @param onLongPress Optional callback to be invoked when long pressing an item. + * @param onExitLongPress Optional callback to be invoked when the item is dragged after long press. + */ +@Suppress("LongParameterList") +class ListReorderState internal constructor( + private val listState: LazyListState, + private val scope: CoroutineScope, + private val hapticFeedback: HapticFeedback, + private val touchSlop: Float, + private val onMove: (LazyListItemInfo, LazyListItemInfo) -> Unit, + private val ignoredItems: List, + private val onLongPress: (LazyListItemInfo) -> Unit, + private val onExitLongPress: () -> Unit, +) { + var draggingItemKey by mutableStateOf(null) + private set + + private var draggingItemCumulatedOffset by mutableFloatStateOf(0f) + private var draggingItemInitialOffset by mutableFloatStateOf(0f) + internal var moved by mutableStateOf(false) + private val draggingItemOffset: Float + get() = draggingItemLayoutInfo?.let { item -> + draggingItemInitialOffset + draggingItemCumulatedOffset - item.offset + } ?: 0f + + internal fun computeItemOffset(index: Int): Float { + val itemAtIndex = listState.layoutInfo.visibleItemsInfo.firstOrNull { info -> info.index == index } + ?: return draggingItemOffset + return draggingItemInitialOffset + draggingItemCumulatedOffset - itemAtIndex.offset + } + + private val draggingItemLayoutInfo: LazyListItemInfo? + get() = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key == draggingItemKey } + + internal var previousKeyOfDraggedItem by mutableStateOf(null) + private set + internal var previousItemOffset = Animatable(0f) + private set + + internal fun onTouchSlopPassed(offset: Float, shouldLongPress: Boolean) { + listState.findItem(offset)?.also { + draggingItemKey = it.key + if (shouldLongPress) { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + onLongPress(it) + } + draggingItemInitialOffset = it.offset.toFloat() + moved = !shouldLongPress + } + } + + internal fun onDragInterrupted() { + if (draggingItemKey != null) { + previousKeyOfDraggedItem = draggingItemKey + val startOffset = draggingItemOffset + scope.launch { + previousItemOffset.snapTo(startOffset) + previousItemOffset.animateTo( + 0f, + spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = 1f, + ), + ) + previousKeyOfDraggedItem = null + } + } + draggingItemCumulatedOffset = 0f + draggingItemKey = null + draggingItemInitialOffset = 0f + } + + internal fun onDrag(offset: Float) { + draggingItemCumulatedOffset += offset + + if (draggingItemLayoutInfo == null) { + moved = false + } + val draggingItem = draggingItemLayoutInfo ?: return + + if (!moved && abs(draggingItemCumulatedOffset) > touchSlop) { + onExitLongPress() + } + val startOffset = draggingItem.offset + draggingItemOffset + val endOffset = startOffset + draggingItem.size + val middleOffset = startOffset + (endOffset - startOffset) / 2f + + val targetItem = listState.layoutInfo.visibleItemsInfo.find { item -> + middleOffset.toInt() in item.offset..item.endOffset && draggingItemKey != item.key + } + + if (targetItem != null && targetItem.key !in ignoredItems) { + if (draggingItem.index == listState.firstVisibleItemIndex || + targetItem.index == listState.firstVisibleItemIndex + ) { + scope.launch { + onMove.invoke(draggingItem, targetItem) + listState.scrollBy(draggingItem.size.toFloat()) + } + } else { + onMove.invoke(draggingItem, targetItem) + } + } else { + val overscroll = when { + draggingItemCumulatedOffset > 0 -> + (endOffset - listState.layoutInfo.viewportEndOffset).coerceAtLeast(0f) + + draggingItemCumulatedOffset < 0 -> + (startOffset - listState.layoutInfo.viewportStartOffset).coerceAtMost(0f) + + else -> 0f + } + if (overscroll != 0f) { + scope.launch { + listState.scrollBy(overscroll) + } + } + } + } +} + +/** + * Container for draggable list item. + * + * @param state List reordering state. + * @param key Key of the item to be displayed. + * @param position Position in the list of the item to be displayed. + * @param content Content of the item to be displayed. + */ +@ExperimentalFoundationApi +@Composable +fun LazyItemScope.DragItemContainer( + state: ListReorderState, + key: Any, + position: Int, + content: @Composable () -> Unit, +) { + val modifier = when (key) { + state.draggingItemKey -> { + Modifier + .zIndex(1f) + .graphicsLayer { + translationY = state.computeItemOffset(position) + } + } + + state.previousKeyOfDraggedItem -> { + Modifier + .zIndex(1f) + .graphicsLayer { + translationY = state.previousItemOffset.value + } + } + + else -> { + Modifier + .zIndex(0f) + .animateItemPlacement(tween()) + } + } + Box(modifier = modifier, propagateMinConstraints = true) { + content() + } +} + +/** + * Calculates the offset of an item taking its height into account. + */ +private val LazyListItemInfo.endOffset: Int + get() = offset + size + +/** + * Find item based on position on screen. + * + * @param offset Position on screen used to find the item. + */ +private fun LazyListState.findItem(offset: Float) = + layoutInfo.visibleItemsInfo.firstOrNull { item -> + offset.toInt() in item.offset..item.endOffset + } + +/** + * Detects press, long press and drag gestures. + * + * @param listState State of the list. + * @param reorderState List reordering state used for dragging callbacks. + * @param shouldLongPressToDrag Whether or not an item should be long pressed to start the dragging gesture. + */ +fun Modifier.detectVerticalPressAndDrag( + listState: LazyListState, + reorderState: ListReorderState, + shouldLongPressToDrag: Boolean, +): Modifier = pointerInput(listState, shouldLongPressToDrag) { + if (shouldLongPressToDrag) { + detectDragGesturesAfterLongPress( + onDragStart = { offset -> reorderState.onTouchSlopPassed(offset.y, true) }, + onDrag = { change, dragAmount -> + change.consume() + reorderState.onDrag(dragAmount.y) + }, + onDragEnd = reorderState::onDragInterrupted, + onDragCancel = reorderState::onDragInterrupted, + ) + } +} diff --git a/app/src/main/java/net/waterfox/android/tabstray/ext/BrowserMenu.kt b/app/src/main/java/net/waterfox/android/tabstray/ext/BrowserMenu.kt index a210b2ee4..82feff3f5 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/ext/BrowserMenu.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/ext/BrowserMenu.kt @@ -18,8 +18,8 @@ fun BrowserMenu.showWithTheme(view: View) { (popupMenu.contentView as? CardView)?.setCardBackgroundColor( ContextCompat.getColor( view.context, - R.color.fx_mobile_layer_color_1 - ) + R.color.fx_mobile_layer_color_2, + ), ) } } diff --git a/app/src/main/java/net/waterfox/android/tabstray/ext/WaterfoxSnackbar.kt b/app/src/main/java/net/waterfox/android/tabstray/ext/FenixSnackbar.kt similarity index 93% rename from app/src/main/java/net/waterfox/android/tabstray/ext/WaterfoxSnackbar.kt rename to app/src/main/java/net/waterfox/android/tabstray/ext/FenixSnackbar.kt index b637d2a21..6edc3e64d 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/ext/WaterfoxSnackbar.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/ext/FenixSnackbar.kt @@ -11,7 +11,7 @@ import net.waterfox.android.tabstray.TabsTrayFragment.Companion.ELEVATION internal fun WaterfoxSnackbar.collectionMessage( tabSize: Int, - isNewCollection: Boolean = false + isNewCollection: Boolean = false, ): WaterfoxSnackbar { val stringRes = when { isNewCollection -> { @@ -29,7 +29,7 @@ internal fun WaterfoxSnackbar.collectionMessage( } internal fun WaterfoxSnackbar.bookmarkMessage( - tabSize: Int + tabSize: Int, ): WaterfoxSnackbar { val stringRes = when { tabSize > 1 -> { @@ -45,7 +45,7 @@ internal fun WaterfoxSnackbar.bookmarkMessage( internal inline fun WaterfoxSnackbar.anchorWithAction( anchor: View?, - crossinline action: () -> Unit + crossinline action: () -> Unit, ): WaterfoxSnackbar { anchorView = anchor view.elevation = ELEVATION @@ -60,5 +60,5 @@ internal inline fun WaterfoxSnackbar.anchorWithAction( internal fun WaterfoxSnackbar.Companion.make(view: View) = make( duration = LENGTH_LONG, isDisplayedWithBrowserToolbar = true, - view = view + view = view, ) diff --git a/app/src/main/java/net/waterfox/android/tabstray/ext/RecyclerViewAdapter.kt b/app/src/main/java/net/waterfox/android/tabstray/ext/RecyclerViewAdapter.kt index 81c48be36..bb3a9bee3 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/ext/RecyclerViewAdapter.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/ext/RecyclerViewAdapter.kt @@ -12,8 +12,14 @@ import androidx.recyclerview.widget.RecyclerView fun RecyclerView.Adapter.observeFirstInsert(block: () -> Unit) { val observer = object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - block.invoke() - unregisterAdapterDataObserver(this) + // There's a bug where [onItemRangeInserted] is intermittently called with an [itemCount] of zero, causing + // the Tabs Tray to always open scrolled at the top. This check forces [onItemRangeInserted] to wait + // until [itemCount] is non-zero to execute [block] and remove the adapter observer. + // This is a temporary fix until the Compose rewrite is enabled by default, where this bug is not present. + if (itemCount > 0) { + block.invoke() + unregisterAdapterDataObserver(this) + } } } registerAdapterDataObserver(observer) diff --git a/app/src/main/java/net/waterfox/android/tabstray/ext/SyncedDeviceTabs.kt b/app/src/main/java/net/waterfox/android/tabstray/ext/SyncedDeviceTabs.kt index 859f6a3d4..18c53f228 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/ext/SyncedDeviceTabs.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/ext/SyncedDeviceTabs.kt @@ -11,32 +11,16 @@ import net.waterfox.android.tabstray.syncedtabs.SyncedTabsListItem /** * Converts a list of [SyncedDeviceTabs] into a list of [SyncedTabsListItem]. */ -fun List.toComposeList( - taskContinuityEnabled: Boolean, -): List = asSequence().flatMap { (device, tabs) -> - if (taskContinuityEnabled) { - val deviceTabs = if (tabs.isEmpty()) { - emptyList() - } else { - tabs.map { - val url = it.active().url - val titleText = it.active().title.ifEmpty { url.trimmed() } - SyncedTabsListItem.Tab(titleText, url, it) - } - } - - sequenceOf(SyncedTabsListItem.DeviceSection(device.displayName, deviceTabs)) +fun List.toComposeList(): List = asSequence().flatMap { (device, tabs) -> + val deviceTabs = if (tabs.isEmpty()) { + emptyList() } else { - val deviceTabs = if (tabs.isEmpty()) { - sequenceOf(SyncedTabsListItem.NoTabs) - } else { - tabs.asSequence().map { - val url = it.active().url - val titleText = it.active().title.ifEmpty { url.trimmed() } - SyncedTabsListItem.Tab(titleText, url, it) - } + tabs.map { + val url = it.active().url + val titleText = it.active().title.ifEmpty { url.trimmed() } + SyncedTabsListItem.Tab(titleText, url, it) } - - sequenceOf(SyncedTabsListItem.Device(device.displayName)) + deviceTabs } + + sequenceOf(SyncedTabsListItem.DeviceSection(device.displayName, deviceTabs)) }.toList() diff --git a/app/src/main/java/net/waterfox/android/tabstray/ext/SyncedTabsViewErrorType.kt b/app/src/main/java/net/waterfox/android/tabstray/ext/SyncedTabsViewErrorType.kt index 7c0edf62c..6f56a964f 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/ext/SyncedTabsViewErrorType.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/ext/SyncedTabsViewErrorType.kt @@ -39,7 +39,7 @@ fun SyncedTabsView.ErrorType.toSyncedTabsListItem(context: Context, navControlle SyncedTabsListItem.Error( errorText = context.getString(R.string.synced_tabs_sign_in_message), errorButton = SyncedTabsListItem.ErrorButton( - buttonText = context.getString(R.string.synced_tabs_sign_in_button) + buttonText = context.getString(R.string.synced_tabs_sign_in_button), ) { navController.navigate( NavGraphDirections.actionGlobalTurnOnSync( diff --git a/app/src/main/java/net/waterfox/android/tabstray/ext/TabSelectors.kt b/app/src/main/java/net/waterfox/android/tabstray/ext/TabSelectors.kt index e828aeeb9..fcbb18127 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/ext/TabSelectors.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/ext/TabSelectors.kt @@ -35,7 +35,7 @@ fun BrowserState.findPrivateTab(tabId: String): TabSessionState? { * The list of normal tabs in the tabs tray filtered appropriately based on feature flags. */ fun BrowserState.getNormalTrayTabs( - inactiveTabsEnabled: Boolean + inactiveTabsEnabled: Boolean, ): List { return normalTabs.run { if (inactiveTabsEnabled) { diff --git a/app/src/main/java/net/waterfox/android/tabstray/ext/TabsTrayState.kt b/app/src/main/java/net/waterfox/android/tabstray/ext/TabsTrayState.kt index 3df6008fa..74d895294 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/ext/TabsTrayState.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/ext/TabsTrayState.kt @@ -4,9 +4,181 @@ package net.waterfox.android.tabstray.ext +import android.content.res.Resources +import net.waterfox.android.R +import net.waterfox.android.compose.MenuItem +import net.waterfox.android.tabstray.Page import net.waterfox.android.tabstray.TabsTrayState.Mode +import net.waterfox.android.tabstray.TabsTrayTestTag /** * A helper to check if we're in [Mode.Select] mode. */ fun Mode.isSelect() = this is Mode.Select + +/** + * Returns the list of menu items corresponding to the selected mode + * + * @param resources The resources used to provide the strings for the menu item titles. + * @param shouldShowInactiveButton Whether or not to show the inactive tabs menu item. + * @param selectedPage The currently selected page. + * @param normalTabCount The normal tabs number. + * @param privateTabCount The private tabs number. + * @param onBookmarkSelectedTabsClick Invoked when user interacts with the bookmark menu item. + * @param onCloseSelectedTabsClick Invoked when user interacts with the close menu item. + * @param onMakeSelectedTabsInactive Invoked when user interacts with the make inactive menu item. + * @param onTabSettingsClick Invoked when user interacts with the tab settings menu. + * @param onRecentlyClosedClick Invoked when user interacts with the recently closed menu item. + * @param onEnterMultiselectModeClick Invoked when user enters the multiselect mode. + * @param onShareAllTabsClick Invoked when user interacts with the share all menu item. + * @param onDeleteAllTabsClick Invoked when user interacts with the delete all menu item. + * @param onAccountSettingsClick Invoked when user interacts with the account settings. + */ +@Suppress("LongParameterList") +fun Mode.getMenuItems( + resources: Resources, + shouldShowInactiveButton: Boolean, + selectedPage: Page, + normalTabCount: Int, + privateTabCount: Int, + onBookmarkSelectedTabsClick: () -> Unit, + onCloseSelectedTabsClick: () -> Unit, + onMakeSelectedTabsInactive: () -> Unit, + onTabSettingsClick: () -> Unit, + onRecentlyClosedClick: () -> Unit, + onEnterMultiselectModeClick: () -> Unit, + onShareAllTabsClick: () -> Unit, + onDeleteAllTabsClick: () -> Unit, + onAccountSettingsClick: () -> Unit, +): List { + return if (this.isSelect()) { + generateMultiSelectBannerMenuItems( + resources = resources, + shouldShowInactiveButton = shouldShowInactiveButton, + onBookmarkSelectedTabsClick = onBookmarkSelectedTabsClick, + onCloseSelectedTabsClick = onCloseSelectedTabsClick, + onMakeSelectedTabsInactive = onMakeSelectedTabsInactive, + ) + } else { + generateSingleSelectBannerMenuItems( + resources = resources, + selectedPage = selectedPage, + normalTabCount = normalTabCount, + privateTabCount = privateTabCount, + onTabSettingsClick = onTabSettingsClick, + onRecentlyClosedClick = onRecentlyClosedClick, + onEnterMultiselectModeClick = onEnterMultiselectModeClick, + onShareAllTabsClick = onShareAllTabsClick, + onDeleteAllTabsClick = onDeleteAllTabsClick, + onAccountSettingsClick = onAccountSettingsClick, + ) + } +} + +/** + * Builds the menu items list when in multiselect mode + */ +private fun generateMultiSelectBannerMenuItems( + resources: Resources, + shouldShowInactiveButton: Boolean, + onBookmarkSelectedTabsClick: () -> Unit, + onCloseSelectedTabsClick: () -> Unit, + onMakeSelectedTabsInactive: () -> Unit, +): List { + val menuItems = mutableListOf( + MenuItem( + title = resources.getString(R.string.tab_tray_multiselect_menu_item_bookmark), + onClick = onBookmarkSelectedTabsClick, + ), + MenuItem( + title = resources.getString(R.string.tab_tray_multiselect_menu_item_close), + onClick = onCloseSelectedTabsClick, + ), + ) + if (shouldShowInactiveButton) { + menuItems.add( + MenuItem( + title = resources.getString(R.string.inactive_tabs_menu_item), + onClick = onMakeSelectedTabsInactive, + ), + ) + } + + return menuItems +} + +/** + * Builds the menu items list when in normal mode + */ +@Suppress("LongParameterList") +private fun generateSingleSelectBannerMenuItems( + resources: Resources, + selectedPage: Page, + normalTabCount: Int, + privateTabCount: Int, + onTabSettingsClick: () -> Unit, + onRecentlyClosedClick: () -> Unit, + onEnterMultiselectModeClick: () -> Unit, + onShareAllTabsClick: () -> Unit, + onDeleteAllTabsClick: () -> Unit, + onAccountSettingsClick: () -> Unit, +): List { + val tabSettingsItem = MenuItem( + title = resources.getString(R.string.tab_tray_menu_tab_settings), + testTag = TabsTrayTestTag.tabSettings, + onClick = onTabSettingsClick, + ) + val recentlyClosedTabsItem = MenuItem( + title = resources.getString(R.string.tab_tray_menu_recently_closed), + testTag = TabsTrayTestTag.recentlyClosedTabs, + onClick = onRecentlyClosedClick, + ) + val enterSelectModeItem = MenuItem( + title = resources.getString(R.string.tabs_tray_select_tabs), + testTag = TabsTrayTestTag.selectTabs, + onClick = onEnterMultiselectModeClick, + ) + val shareAllTabsItem = MenuItem( + title = resources.getString(R.string.tab_tray_menu_item_share), + testTag = TabsTrayTestTag.shareAllTabs, + onClick = onShareAllTabsClick, + ) + val deleteAllTabsItem = MenuItem( + title = resources.getString(R.string.tab_tray_menu_item_close), + testTag = TabsTrayTestTag.closeAllTabs, + onClick = onDeleteAllTabsClick, + ) + val accountSettingsItem = MenuItem( + title = resources.getString(R.string.tab_tray_menu_account_settings), + testTag = TabsTrayTestTag.accountSettings, + onClick = onAccountSettingsClick, + ) + return when { + selectedPage == Page.NormalTabs && normalTabCount == 0 || + selectedPage == Page.PrivateTabs && privateTabCount == 0 -> listOf( + tabSettingsItem, + recentlyClosedTabsItem, + ) + + selectedPage == Page.NormalTabs -> listOf( + enterSelectModeItem, + shareAllTabsItem, + tabSettingsItem, + recentlyClosedTabsItem, + deleteAllTabsItem, + ) + + selectedPage == Page.PrivateTabs -> listOf( + tabSettingsItem, + recentlyClosedTabsItem, + deleteAllTabsItem, + ) + + selectedPage == Page.SyncedTabs -> listOf( + accountSettingsItem, + recentlyClosedTabsItem, + ) + + else -> emptyList() + } +} diff --git a/app/src/main/java/net/waterfox/android/tabstray/inactivetabs/InactiveTabs.kt b/app/src/main/java/net/waterfox/android/tabstray/inactivetabs/InactiveTabs.kt index dfb843d5c..9d2e6e208 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/inactivetabs/InactiveTabs.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/inactivetabs/InactiveTabs.kt @@ -45,7 +45,6 @@ import net.waterfox.android.compose.list.FaviconListItem import net.waterfox.android.ext.toShortUrl import net.waterfox.android.tabstray.ext.toDisplayTitle import net.waterfox.android.theme.WaterfoxTheme -import net.waterfox.android.theme.Theme private val ROUNDED_CORNER_SHAPE = RoundedCornerShape(8.dp) @@ -68,7 +67,7 @@ fun InactiveTabsList( inactiveTabs: List, expanded: Boolean, showAutoCloseDialog: Boolean, - onHeaderClick: () -> Unit, + onHeaderClick: (Boolean) -> Unit, onDeleteAllButtonClick: () -> Unit, onAutoCloseDismissClick: () -> Unit, onEnableAutoCloseClick: () -> Unit, @@ -85,11 +84,11 @@ fun InactiveTabsList( ), ) { Column( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { InactiveTabsHeader( expanded = expanded, - onClick = onHeaderClick, + onClick = { onHeaderClick(!expanded) }, onDeleteAllClick = onDeleteAllButtonClick, ) @@ -115,7 +114,7 @@ fun InactiveTabsList( faviconPainter = faviconPainter, onClick = { onTabClick(tab) }, url = tabUrl, - iconPainter = painterResource(R.drawable.mozac_ic_cross_20), + iconPainter = painterResource(R.drawable.mozac_ic_cross_24), iconDescription = stringResource(R.string.content_description_close_button), onIconClick = { onTabCloseClick(tab) }, ) @@ -195,12 +194,12 @@ private fun InactiveTabsAutoClosePrompt( text = stringResource(R.string.tab_tray_inactive_auto_close_title), color = WaterfoxTheme.colors.textPrimary, modifier = Modifier.weight(1f), - style = WaterfoxTheme.typography.headline8 + style = WaterfoxTheme.typography.headline8, ) IconButton( onClick = onDismissClick, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(20.dp), ) { Icon( painter = painterResource(R.drawable.mozac_ic_cross_20), @@ -214,7 +213,7 @@ private fun InactiveTabsAutoClosePrompt( Text( text = stringResource( R.string.tab_tray_inactive_auto_close_body_2, - stringResource(R.string.app_name) + stringResource(R.string.app_name), ), color = WaterfoxTheme.colors.textSecondary, modifier = Modifier.fillMaxWidth(), @@ -233,7 +232,7 @@ private fun InactiveTabsAutoClosePrompt( @Preview(name = "Auto close dialog dark", uiMode = Configuration.UI_MODE_NIGHT_YES) @Preview(name = "Auto close dialog light", uiMode = Configuration.UI_MODE_NIGHT_NO) private fun InactiveTabsAutoClosePromptPreview() { - WaterfoxTheme(theme = Theme.getTheme()) { + WaterfoxTheme { Box(Modifier.background(WaterfoxTheme.colors.layer1)) { InactiveTabsAutoClosePrompt( onDismissClick = {}, @@ -250,7 +249,7 @@ private fun InactiveTabsListPreview() { var expanded by remember { mutableStateOf(true) } var showAutoClosePrompt by remember { mutableStateOf(true) } - WaterfoxTheme(theme = Theme.getTheme()) { + WaterfoxTheme { Box(Modifier.background(WaterfoxTheme.colors.layer1)) { InactiveTabsList( inactiveTabs = generateFakeInactiveTabsList(), @@ -273,12 +272,12 @@ private fun generateFakeInactiveTabsList(): List = id = "tabId", content = ContentState( url = "www.mozilla.com", - ) + ), ), TabSessionState( id = "tabId", content = ContentState( url = "www.google.com", - ) + ), ), ) diff --git a/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncButtonBinding.kt b/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncButtonBinding.kt index abead2fb4..0d959dc59 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncButtonBinding.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncButtonBinding.kt @@ -6,10 +6,10 @@ package net.waterfox.android.tabstray.syncedtabs import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import mozilla.components.feature.syncedtabs.view.SyncedTabsView import mozilla.components.lib.state.helpers.AbstractBinding -import kotlinx.coroutines.flow.distinctUntilChanged import net.waterfox.android.tabstray.TabsTrayState import net.waterfox.android.tabstray.TabsTrayStore @@ -22,7 +22,7 @@ import net.waterfox.android.tabstray.TabsTrayStore @OptIn(ExperimentalCoroutinesApi::class) class SyncButtonBinding( tabsTrayStore: TabsTrayStore, - private val onSyncNow: () -> Unit + private val onSyncNow: () -> Unit, ) : AbstractBinding(tabsTrayStore) { override suspend fun onState(flow: Flow) { flow.map { it.syncing } diff --git a/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncedTabs.kt b/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncedTabs.kt index 558c2a1ec..ef2b914d4 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncedTabs.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncedTabs.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -39,8 +40,8 @@ import net.waterfox.android.compose.button.PrimaryButton import net.waterfox.android.compose.ext.dashedBorder import net.waterfox.android.compose.list.ExpandableListHeader import net.waterfox.android.compose.list.FaviconListItem +import net.waterfox.android.tabstray.TabsTrayTestTag import net.waterfox.android.theme.WaterfoxTheme -import net.waterfox.android.theme.Theme import mozilla.components.browser.storage.sync.Tab as SyncTab private const val EXPANDED_BY_DEFAULT = true @@ -49,7 +50,6 @@ private const val EXPANDED_BY_DEFAULT = true * Top-level list UI for displaying Synced Tabs in the Tabs Tray. * * @param syncedTabs The tab UI items to be displayed. - * @param taskContinuityEnabled Indicates whether the Task Continuity enhancements should be visible for users. * @param onTabClick The lambda for handling clicks on synced tabs. */ @SuppressWarnings("LongMethod") @@ -57,7 +57,6 @@ private const val EXPANDED_BY_DEFAULT = true @Composable fun SyncedTabsList( syncedTabs: List, - taskContinuityEnabled: Boolean, onTabClick: (SyncTab) -> Unit, ) { val listState = rememberLazyListState() @@ -65,73 +64,51 @@ fun SyncedTabsList( remember(syncedTabs) { syncedTabs.map { EXPANDED_BY_DEFAULT }.toMutableStateList() } LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .testTag(TabsTrayTestTag.syncedTabsList), state = listState, ) { - if (taskContinuityEnabled) { - syncedTabs.forEachIndexed { index, syncedTabItem -> - when (syncedTabItem) { - is SyncedTabsListItem.DeviceSection -> { - val sectionExpanded = expandedState[index] + syncedTabs.forEachIndexed { index, syncedTabItem -> + when (syncedTabItem) { + is SyncedTabsListItem.DeviceSection -> { + val sectionExpanded = expandedState[index] - stickyHeader { - SyncedTabsSectionHeader( - headerText = syncedTabItem.displayName, - expanded = sectionExpanded, - ) { - expandedState[index] = !sectionExpanded - } - } - - if (sectionExpanded) { - if (syncedTabItem.tabs.isNotEmpty()) { - items(syncedTabItem.tabs) { syncedTab -> - FaviconListItem( - label = syncedTab.displayTitle, - description = syncedTab.displayURL, - url = syncedTab.displayURL, - onClick = { onTabClick(syncedTab.tab) }, - ) - } - } else { - item { SyncedTabsNoTabsItem() } - } + stickyHeader { + SyncedTabsSectionHeader( + headerText = syncedTabItem.displayName, + expanded = sectionExpanded, + ) { + expandedState[index] = !sectionExpanded } } - is SyncedTabsListItem.Error -> { - item { - SyncedTabsErrorItem( - errorText = syncedTabItem.errorText, - errorButton = syncedTabItem.errorButton - ) + if (sectionExpanded) { + if (syncedTabItem.tabs.isNotEmpty()) { + items(syncedTabItem.tabs) { syncedTab -> + FaviconListItem( + label = syncedTab.displayTitle, + description = syncedTab.displayURL, + url = syncedTab.displayURL, + onClick = { onTabClick(syncedTab.tab) }, + ) + } + } else { + item { SyncedTabsNoTabsItem() } } } - else -> { - // no-op - } } - } - } else { - items(syncedTabs) { syncedTabItem -> - when (syncedTabItem) { - is SyncedTabsListItem.Device -> SyncedTabsSectionHeader(headerText = syncedTabItem.displayName) - is SyncedTabsListItem.Error -> SyncedTabsErrorItem( - errorText = syncedTabItem.errorText, - errorButton = syncedTabItem.errorButton - ) - is SyncedTabsListItem.NoTabs -> SyncedTabsNoTabsItem() - is SyncedTabsListItem.Tab -> { - FaviconListItem( - label = syncedTabItem.displayTitle, - description = syncedTabItem.displayURL, - url = syncedTabItem.displayURL, - onClick = { onTabClick(syncedTabItem.tab) }, + + is SyncedTabsListItem.Error -> { + item { + SyncedTabsErrorItem( + errorText = syncedTabItem.errorText, + errorButton = syncedTabItem.errorButton, ) } - else -> { - // no-op - } + } + else -> { + // no-op } } } @@ -160,7 +137,7 @@ fun SyncedTabsSectionHeader( Column( modifier = Modifier .fillMaxWidth() - .background(WaterfoxTheme.colors.layer1) + .background(WaterfoxTheme.colors.layer1), ) { ExpandableListHeader( headerText = headerText, @@ -183,7 +160,7 @@ fun SyncedTabsSectionHeader( @Composable fun SyncedTabsErrorItem( errorText: String, - errorButton: SyncedTabsListItem.ErrorButton? = null + errorButton: SyncedTabsListItem.ErrorButton? = null, ) { Box( Modifier @@ -193,19 +170,19 @@ fun SyncedTabsErrorItem( color = WaterfoxTheme.colors.borderPrimary, cornerRadius = 8.dp, dashHeight = 2.dp, - dashWidth = 4.dp - ) + dashWidth = 4.dp, + ), ) { Column( Modifier .padding(all = 16.dp) - .fillMaxWidth() + .fillMaxWidth(), ) { Text( text = errorText, color = WaterfoxTheme.colors.textPrimary, modifier = Modifier.fillMaxWidth(), - fontSize = 14.sp + fontSize = 14.sp, ) errorButton?.let { @@ -233,14 +210,14 @@ fun SyncedTabsNoTabsItem() { .padding(horizontal = 16.dp, vertical = 8.dp) .fillMaxWidth(), fontSize = 16.sp, - maxLines = 1 + maxLines = 1, ) } @Composable @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) private fun SyncedTabsListItemsPreview() { - WaterfoxTheme(theme = Theme.getTheme()) { + WaterfoxTheme { Column(Modifier.background(WaterfoxTheme.colors.layer1)) { SyncedTabsSectionHeader(headerText = "Google Pixel Pro Max +Ultra 5000") @@ -276,12 +253,12 @@ private fun SyncedTabsListItemsPreview() { @Composable @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) private fun SyncedTabsErrorPreview() { - WaterfoxTheme(theme = Theme.getTheme()) { + WaterfoxTheme { Box(Modifier.background(WaterfoxTheme.colors.layer1)) { SyncedTabsErrorItem( errorText = stringResource(R.string.synced_tabs_no_tabs), errorButton = SyncedTabsListItem.ErrorButton( - buttonText = stringResource(R.string.synced_tabs_sign_in_button) + buttonText = stringResource(R.string.synced_tabs_sign_in_button), ) { println("SyncedTabsErrorButton click") }, @@ -293,11 +270,10 @@ private fun SyncedTabsErrorPreview() { @Composable @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) private fun SyncedTabsListPreview() { - WaterfoxTheme(theme = Theme.getTheme()) { + WaterfoxTheme { Box(Modifier.background(WaterfoxTheme.colors.layer1)) { SyncedTabsList( syncedTabs = getFakeSyncedTabList(), - taskContinuityEnabled = true, ) { println("Tab clicked") } @@ -316,7 +292,7 @@ internal fun getFakeSyncedTabList(): List = listOf( generateFakeTab("Mozilla", "www.mozilla.org"), generateFakeTab("Google", "www.google.com"), generateFakeTab("", "www.google.com"), - ) + ), ), SyncedTabsListItem.DeviceSection("Device 2", emptyList()), SyncedTabsListItem.Error("Please re-authenticate"), @@ -333,5 +309,5 @@ private fun generateFakeTab(tabName: String, tabUrl: String): SyncedTabsListItem history = listOf(TabEntry(tabName, tabUrl, null)), active = 0, lastUsed = 0L, - ) + ), ) diff --git a/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncedTabsIntegration.kt b/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncedTabsIntegration.kt index 13505c6c3..a38516a09 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncedTabsIntegration.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncedTabsIntegration.kt @@ -15,7 +15,6 @@ 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 net.waterfox.android.ext.settings import net.waterfox.android.tabstray.FloatingActionButtonBinding import net.waterfox.android.tabstray.TabsTrayAction import net.waterfox.android.tabstray.TabsTrayStore @@ -25,7 +24,7 @@ import net.waterfox.android.tabstray.ext.toSyncedTabsListItem /** * TabsTrayFragment delegate to handle all layout updates needed to display synced tabs and any errors. * - * @param store [TabsTrayStore] + * @param store An instance of [TabsTrayStore] used to manage the tabs tray state. * @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. @@ -53,7 +52,7 @@ class SyncedTabsIntegration( onTabClicked = { // We can ignore this callback here because we're not connecting the Compose UI // back to the feature. - } + }, ) } @@ -92,10 +91,8 @@ class SyncedTabsIntegration( override fun displaySyncedTabs(syncedTabs: List) { store.dispatch( TabsTrayAction.UpdateSyncedTabs( - syncedTabs.toComposeList( - context.settings().enableTaskContinuityEnhancements - ) - ) + syncedTabs.toComposeList(), + ), ) } } diff --git a/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncedTabsListItem.kt b/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncedTabsListItem.kt index 3c45310ec..6c8e48d8f 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncedTabsListItem.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/syncedtabs/SyncedTabsListItem.kt @@ -14,29 +14,29 @@ sealed class SyncedTabsListItem { /** * A device header for displaying a synced device. * - * @param displayName The user's custom name of their synced device. + * @property displayName The user's custom name of their synced device. */ data class Device(val displayName: String) : SyncedTabsListItem() /** * A section for displaying a synced device and its tabs. * - * @param displayName The user's custom name of their synced device. - * @param tabs The user's tabs from their synced device. + * @property displayName The user's custom name of their synced device. + * @property tabs The user's tabs from their synced device. */ data class DeviceSection(val displayName: String, val tabs: List) : 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. + * @property displayTitle The title of the tab's web page. + * @property displayURL The tab's URL up to BrowserToolbar.MAX_URI_LENGTH characters long. + * @property tab The underlying SyncTab object passed when the tab is clicked. */ data class Tab( val displayTitle: String, val displayURL: String, - val tab: SyncTab + val tab: SyncTab, ) : SyncedTabsListItem() /** @@ -47,8 +47,8 @@ sealed class 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. + * @property errorText The text to be displayed to the user. + * @property errorButton Optional class to set up and handle any clicks in the Error UI. */ data class Error( val errorText: String, @@ -58,12 +58,11 @@ sealed class 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. - * + * @property buttonText The error button's text and accessibility hint. + * @property onClick Lambda called when the button is clicked. */ data class ErrorButton( val buttonText: String, - val onClick: () -> Unit + val onClick: () -> Unit, ) } diff --git a/app/src/main/java/net/waterfox/android/tabstray/viewholders/AbstractBrowserPageViewHolder.kt b/app/src/main/java/net/waterfox/android/tabstray/viewholders/AbstractBrowserPageViewHolder.kt index 4bb2d00af..fe25da259 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/viewholders/AbstractBrowserPageViewHolder.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/viewholders/AbstractBrowserPageViewHolder.kt @@ -42,13 +42,13 @@ abstract class AbstractBrowserPageViewHolder( */ abstract fun scrollToTab( adapter: RecyclerView.Adapter, - layoutManager: RecyclerView.LayoutManager + layoutManager: RecyclerView.LayoutManager, ) @CallSuper protected fun bind( adapter: RecyclerView.Adapter, - layoutManager: RecyclerView.LayoutManager + layoutManager: RecyclerView.LayoutManager, ) { adapterRef = adapter @@ -96,6 +96,7 @@ abstract class AbstractBrowserPageViewHolder( adapterObserver = null } } + /** * A way for an implementor of [AbstractBrowserPageViewHolder] to define their own behavior of * when to show/hide the tray list and empty list UI. diff --git a/app/src/main/java/net/waterfox/android/tabstray/viewholders/AbstractPageViewHolder.kt b/app/src/main/java/net/waterfox/android/tabstray/viewholders/AbstractPageViewHolder.kt index 36f232cd7..305e1ffc0 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/viewholders/AbstractPageViewHolder.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/viewholders/AbstractPageViewHolder.kt @@ -12,14 +12,14 @@ import net.waterfox.android.tabstray.TrayPagerAdapter * An abstract [RecyclerView.ViewHolder] for [TrayPagerAdapter] items. */ abstract class AbstractPageViewHolder constructor( - val containerView: View + val containerView: View, ) : RecyclerView.ViewHolder(containerView) { /** * Invoked when the nested [RecyclerView.Adapter] is bound to the [RecyclerView.ViewHolder]. */ abstract fun bind( - adapter: RecyclerView.Adapter + adapter: RecyclerView.Adapter, ) /** diff --git a/app/src/main/java/net/waterfox/android/tabstray/viewholders/NormalBrowserPageViewHolder.kt b/app/src/main/java/net/waterfox/android/tabstray/viewholders/NormalBrowserPageViewHolder.kt index e119b9198..b8c150516 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/viewholders/NormalBrowserPageViewHolder.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/viewholders/NormalBrowserPageViewHolder.kt @@ -10,12 +10,12 @@ import androidx.lifecycle.LifecycleOwner import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import mozilla.components.browser.state.selector.selectedNormalTab import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.store.BrowserStore import mozilla.components.lib.state.ext.flowScoped -import kotlinx.coroutines.flow.distinctUntilChanged import net.waterfox.android.R import net.waterfox.android.components.AppStore import net.waterfox.android.components.appstate.AppAction @@ -59,7 +59,7 @@ class NormalBrowserPageViewHolder( get() = itemView.resources.getString(R.string.no_open_tabs_description) override fun bind( - adapter: RecyclerView.Adapter + adapter: RecyclerView.Adapter, ) { val concatAdapter = adapter as ConcatAdapter val browserAdapter = concatAdapter.browserAdapter @@ -76,7 +76,7 @@ class NormalBrowserPageViewHolder( */ override fun scrollToTab( adapter: RecyclerView.Adapter, - layoutManager: RecyclerView.LayoutManager + layoutManager: RecyclerView.LayoutManager, ) { val concatAdapter = adapter as ConcatAdapter val browserAdapter = concatAdapter.browserAdapter @@ -139,7 +139,7 @@ class NormalBrowserPageViewHolder( private fun setupLayoutManager( context: Context, - concatAdapter: ConcatAdapter + concatAdapter: ConcatAdapter, ): GridLayoutManager { val inactiveTabAdapter = concatAdapter.inactiveTabsAdapter val numberOfColumns = containerView.context.defaultBrowserLayoutColumns diff --git a/app/src/main/java/net/waterfox/android/tabstray/viewholders/PrivateBrowserPageViewHolder.kt b/app/src/main/java/net/waterfox/android/tabstray/viewholders/PrivateBrowserPageViewHolder.kt index 3c15e7ee2..54746c104 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/viewholders/PrivateBrowserPageViewHolder.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/viewholders/PrivateBrowserPageViewHolder.kt @@ -23,7 +23,7 @@ class PrivateBrowserPageViewHolder( containerView: View, tabsTrayStore: TabsTrayStore, private val browserStore: BrowserStore, - interactor: TabsTrayInteractor + interactor: TabsTrayInteractor, ) : AbstractBrowserPageViewHolder( containerView, tabsTrayStore, @@ -35,7 +35,7 @@ class PrivateBrowserPageViewHolder( override fun scrollToTab( adapter: RecyclerView.Adapter, - layoutManager: RecyclerView.LayoutManager + layoutManager: RecyclerView.LayoutManager, ) { adapter.observeFirstInsert { val selectedTab = browserStore.state.selectedPrivateTab ?: return@observeFirstInsert @@ -46,7 +46,7 @@ class PrivateBrowserPageViewHolder( } override fun bind( - adapter: RecyclerView.Adapter + adapter: RecyclerView.Adapter, ) { val context = containerView.context val columns = context.defaultBrowserLayoutColumns diff --git a/app/src/main/java/net/waterfox/android/tabstray/viewholders/SyncedTabsPageViewHolder.kt b/app/src/main/java/net/waterfox/android/tabstray/viewholders/SyncedTabsPageViewHolder.kt index 45a0691be..6ec46af8c 100644 --- a/app/src/main/java/net/waterfox/android/tabstray/viewholders/SyncedTabsPageViewHolder.kt +++ b/app/src/main/java/net/waterfox/android/tabstray/viewholders/SyncedTabsPageViewHolder.kt @@ -8,8 +8,7 @@ import android.view.View import androidx.compose.ui.platform.ComposeView import androidx.recyclerview.widget.RecyclerView import mozilla.components.lib.state.ext.observeAsComposableState -import net.waterfox.android.ext.settings -import net.waterfox.android.tabstray.NavigationInteractor +import net.waterfox.android.tabstray.SyncedTabsInteractor import net.waterfox.android.tabstray.TabsTrayState import net.waterfox.android.tabstray.TabsTrayStore import net.waterfox.android.tabstray.syncedtabs.SyncedTabsList @@ -21,12 +20,12 @@ import net.waterfox.android.theme.Theme * * @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. + * @param interactor [SyncedTabsInteractor] used to respond to interactions with synced tabs. */ class SyncedTabsPageViewHolder( private val composeView: ComposeView, private val tabsTrayStore: TabsTrayStore, - private val navigationInteractor: NavigationInteractor, + private val interactor: SyncedTabsInteractor, ) : AbstractPageViewHolder(composeView) { fun bind() { @@ -35,8 +34,7 @@ class SyncedTabsPageViewHolder( WaterfoxTheme(theme = Theme.getTheme(allowPrivateTheme = false)) { SyncedTabsList( syncedTabs = tabs ?: emptyList(), - taskContinuityEnabled = composeView.context.settings().enableTaskContinuityEnhancements, - onTabClick = navigationInteractor::onSyncedTabClicked, + onTabClick = interactor::onSyncedTabClicked, ) } } diff --git a/app/src/main/java/net/waterfox/android/utils/Settings.kt b/app/src/main/java/net/waterfox/android/utils/Settings.kt index bcbdf008a..81211eef2 100644 --- a/app/src/main/java/net/waterfox/android/utils/Settings.kt +++ b/app/src/main/java/net/waterfox/android/utils/Settings.kt @@ -1187,6 +1187,8 @@ class Settings(private val appContext: Context) : PreferencesHolder { default = setOf() ) + val enableTabsTrayToCompose = true + /** * Get the current mode for how https-only is enabled. */ diff --git a/app/src/main/res/drawable/tab_tray_grid_item_selected_border.xml b/app/src/main/res/drawable/tab_tray_grid_item_selected_border.xml new file mode 100644 index 000000000..e267cbc89 --- /dev/null +++ b/app/src/main/res/drawable/tab_tray_grid_item_selected_border.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/component_tabstray2.xml b/app/src/main/res/layout/component_tabstray2.xml index 5a34bf2a2..3379510c5 100644 --- a/app/src/main/res/layout/component_tabstray2.xml +++ b/app/src/main/res/layout/component_tabstray2.xml @@ -19,6 +19,7 @@ android:layout_height="@dimen/bottom_sheet_handle_height" android:layout_marginTop="@dimen/bottom_sheet_handle_top_margin" android:background="@color/fx_mobile_text_color_secondary" + android:contentDescription="@string/a11y_action_label_collapse" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" diff --git a/app/src/main/res/layout/component_tabstray3.xml b/app/src/main/res/layout/component_tabstray3.xml new file mode 100644 index 000000000..716c6ac0b --- /dev/null +++ b/app/src/main/res/layout/component_tabstray3.xml @@ -0,0 +1,12 @@ + + + diff --git a/app/src/main/res/layout/component_tabstray3_fab.xml b/app/src/main/res/layout/component_tabstray3_fab.xml new file mode 100644 index 000000000..7ff73fcdd --- /dev/null +++ b/app/src/main/res/layout/component_tabstray3_fab.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e8bb9f61..7803dad1a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1767,6 +1767,10 @@ Auto-close enabled + + + collapse + Set links from websites, emails, and messages to open automatically in Waterfox. diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index d3f69b84a..98a78d8d5 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -338,6 +338,13 @@ @anim/fade_out + + + + +