diff --git a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecorator.kt b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecorator.kt index 56e3950d9..b2e6ad625 100644 --- a/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecorator.kt +++ b/app/src/main/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecorator.kt @@ -74,7 +74,7 @@ sealed class RecentTabViewDecorator { val context = itemView.context itemView.background = - AppCompatResources.getDrawable(context, R.drawable.home_list_row_background) + AppCompatResources.getDrawable(context, R.drawable.card_list_row_background) return itemView } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolder.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolder.kt index 83a44e619..1323be2bb 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolder.kt @@ -12,6 +12,7 @@ import mozilla.components.concept.tabstray.Tab import org.mozilla.fenix.R import org.mozilla.fenix.databinding.InactiveFooterItemBinding import org.mozilla.fenix.databinding.InactiveRecentlyClosedItemBinding +import org.mozilla.fenix.databinding.InactiveHeaderItemBinding import org.mozilla.fenix.databinding.InactiveTabListItemBinding import org.mozilla.fenix.ext.components import org.mozilla.fenix.ext.loadIntoView @@ -22,9 +23,32 @@ import org.mozilla.fenix.tabstray.browser.AutoCloseInterval.OneMonth import org.mozilla.fenix.tabstray.browser.AutoCloseInterval.OneWeek sealed class InactiveTabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - class HeaderHolder(itemView: View) : InactiveTabViewHolder(itemView) { + + class HeaderHolder( + itemView: View, + interactor: InactiveTabsInteractor + ) : InactiveTabViewHolder(itemView) { + + private val binding = InactiveHeaderItemBinding.bind(itemView) + + init { + itemView.apply { + isActivated = InactiveTabsState.isExpanded + + setOnClickListener { + val newState = !it.isActivated + + interactor.onHeaderClicked(newState) + + it.isActivated = newState + binding.chevron.rotation = ROTATION_DEGREE + } + } + } + companion object { const val LAYOUT_ID = R.layout.inactive_header_item + private const val ROTATION_DEGREE = 180F } } diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAdapter.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAdapter.kt index 60e0d015e..165f6e333 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsAdapter.kt @@ -39,12 +39,14 @@ class InactiveTabsAdapter( delegate: Observable = ObserverRegistry() ) : Adapter(DiffCallback), TabsTray, Observable by delegate { + internal lateinit var inactiveTabsInteractor: InactiveTabsInteractor + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): InactiveTabViewHolder { val view = LayoutInflater.from(parent.context) .inflate(viewType, parent, false) return when (viewType) { - HeaderHolder.LAYOUT_ID -> HeaderHolder(view) + HeaderHolder.LAYOUT_ID -> HeaderHolder(view, inactiveTabsInteractor) TabViewHolder.LAYOUT_ID -> TabViewHolder(view, browserTrayInteractor) FooterHolder.LAYOUT_ID -> FooterHolder(view) RecentlyClosedHolder.LAYOUT_ID -> RecentlyClosedHolder(view, browserTrayInteractor) @@ -81,12 +83,18 @@ class InactiveTabsAdapter( } override fun updateTabs(tabs: Tabs) { + // Early return with an empty list to remove the header/footer items. if (tabs.list.isEmpty()) { - // Early return with an empty list to remove the header/footer items. submitList(emptyList()) return } + // If we have items, but we should be in a collapsed state. + if (!InactiveTabsState.isExpanded) { + submitList(listOf(Item.Header)) + return + } + val items = tabs.list.map { Item.Tab(it) } val footer = Item.Footer(context.autoCloseInterval) diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsController.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsController.kt new file mode 100644 index 000000000..16f9e94b6 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsController.kt @@ -0,0 +1,28 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.tabstray.TabsTray +import mozilla.components.feature.tabs.ext.toTabs + +class InactiveTabsController( + private val browserStore: BrowserStore, + private val tabFilter: (TabSessionState) -> Boolean, + private val tray: TabsTray +) { + /** + * Updates the inactive card to be expanded to display all the tabs, or collapsed with only + * the title showing. + */ + fun updateCardExpansion(isExpanded: Boolean) { + InactiveTabsState.isExpanded = isExpanded + + val tabs = browserStore.state.toTabs { tabFilter.invoke(it) } + + tray.updateTabs(tabs) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsInteractor.kt new file mode 100644 index 000000000..08d01debb --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/InactiveTabsInteractor.kt @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +interface InactiveTabsInteractor { + fun onHeaderClicked(activated: Boolean) +} + +class DefaultInactiveTabsInteractor( + private val controller: InactiveTabsController +) : InactiveTabsInteractor { + override fun onHeaderClicked(activated: Boolean) { + controller.updateCardExpansion(activated) + } +} + +/** + * An experimental state holder for [InactiveTabsAdapter] that lives at the application lifetime. + * + * TODO This should be replaced with the AppStore. + */ +object InactiveTabsState { + var isExpanded = true +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt b/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt index 21504c8a7..9e12a46a4 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/browser/NormalBrowserTrayList.kt @@ -7,6 +7,7 @@ package org.mozilla.fenix.tabstray.browser import android.content.Context import android.util.AttributeSet import androidx.recyclerview.widget.ConcatAdapter +import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.tabstray.TabViewHolder import mozilla.components.feature.tabs.tabstray.TabsFeature import org.mozilla.fenix.FeatureFlags @@ -53,20 +54,30 @@ class NormalBrowserTrayList @JvmOverloads constructor( ) } + /** + * NB: The setup for this feature is a bit complicated without a better dependency injection + * solution to scope it down to just this view. + */ private val inactiveFeature by lazy { - val tabsAdapter = concatAdapter.inactiveTabsAdapter + val store = context.components.core.store + val tabFilter: (TabSessionState) -> Boolean = filter@{ + if (!FeatureFlags.inactiveTabs) { + return@filter false + } + it.isNormalTabInactive(maxActiveTime) + } + val tabsAdapter = concatAdapter.inactiveTabsAdapter.apply { + inactiveTabsInteractor = DefaultInactiveTabsInteractor( + InactiveTabsController(store, tabFilter, this) + ) + } TabsFeature( tabsAdapter, - context.components.core.store, + store, selectTabUseCase, removeTabUseCase, - { state -> - if (!FeatureFlags.inactiveTabs) { - return@TabsFeature false - } - state.isNormalTabInactive(maxActiveTime) - }, + tabFilter, {} ) } diff --git a/app/src/main/res/drawable/home_list_row_background.xml b/app/src/main/res/drawable/card_list_row_background.xml similarity index 100% rename from app/src/main/res/drawable/home_list_row_background.xml rename to app/src/main/res/drawable/card_list_row_background.xml diff --git a/app/src/main/res/layout/collection_home_list_row.xml b/app/src/main/res/layout/collection_home_list_row.xml index 63e5afbbd..22957cc81 100644 --- a/app/src/main/res/layout/collection_home_list_row.xml +++ b/app/src/main/res/layout/collection_home_list_row.xml @@ -8,7 +8,7 @@ android:layout_width="match_parent" android:layout_height="48dp" android:layout_marginTop="12dp" - android:background="@drawable/home_list_row_background" + android:background="@drawable/card_list_row_background" android:clickable="true" android:clipToPadding="false" android:elevation="@dimen/home_item_elevation" diff --git a/app/src/main/res/layout/inactive_header_item.xml b/app/src/main/res/layout/inactive_header_item.xml index 1bfee4139..2af0c5d37 100644 --- a/app/src/main/res/layout/inactive_header_item.xml +++ b/app/src/main/res/layout/inactive_header_item.xml @@ -9,7 +9,7 @@ android:layout_marginStart="16dp" android:layout_marginTop="12dp" android:layout_marginEnd="16dp" - android:background="@drawable/rounded_top_corners" + android:background="@drawable/card_list_row_background" android:clickable="false" android:clipToPadding="false" android:focusable="true" @@ -31,4 +31,16 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="Inactive tabs" /> + + + \ No newline at end of file diff --git a/app/src/test/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecoratorTest.kt b/app/src/test/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecoratorTest.kt index d4f725152..ee359f6aa 100644 --- a/app/src/test/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecoratorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/home/recenttabs/view/RecentTabViewDecoratorTest.kt @@ -63,7 +63,7 @@ class RecentTabViewDecoratorTest { RecentTabViewDecorator.SingleTabDecoration(view) verify { view.background = drawable } - assertEquals(R.drawable.home_list_row_background, drawableResCaptor.captured) + assertEquals(R.drawable.card_list_row_background, drawableResCaptor.captured) } finally { unmockkStatic(AppCompatResources::class) } diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/browser/DefaultInactiveTabsInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/browser/DefaultInactiveTabsInteractorTest.kt new file mode 100644 index 000000000..36c27520b --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabstray/browser/DefaultInactiveTabsInteractorTest.kt @@ -0,0 +1,22 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +class DefaultInactiveTabsInteractorTest { + + @Test + fun `WHEN onHeaderClicked THEN updateCardExpansion`() { + val controller: InactiveTabsController = mockk(relaxed = true) + val interactor = DefaultInactiveTabsInteractor(controller) + + interactor.onHeaderClicked(true) + + verify { controller.updateCardExpansion(true) } + } +} diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolderTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolderTest.kt new file mode 100644 index 000000000..d33dc9626 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabstray/browser/InactiveTabViewHolderTest.kt @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import android.view.LayoutInflater +import io.mockk.mockk +import io.mockk.verify +import mozilla.components.support.test.robolectric.testContext +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.fenix.helpers.FenixRobolectricTestRunner +import org.mozilla.fenix.tabstray.browser.InactiveTabViewHolder.HeaderHolder + +@RunWith(FenixRobolectricTestRunner::class) +class InactiveTabViewHolderTest { + @Test + fun `HeaderHolder - WHEN clicked THEN notify the interactor`() { + val view = LayoutInflater.from(testContext).inflate(HeaderHolder.LAYOUT_ID, null) + val interactor: InactiveTabsInteractor = mockk(relaxed = true) + val viewHolder = HeaderHolder(view, interactor) + + val initialActivatedState = view.isActivated + + viewHolder.itemView.performClick() + + verify { interactor.onHeaderClicked(any()) } + + assertEquals(!initialActivatedState, view.isActivated) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/browser/InactiveTabsControllerTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/browser/InactiveTabsControllerTest.kt new file mode 100644 index 000000000..207f619fa --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabstray/browser/InactiveTabsControllerTest.kt @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.fenix.tabstray.browser + +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.tabstray.Tabs +import mozilla.components.concept.tabstray.TabsTray +import org.junit.Assert.assertEquals +import mozilla.components.browser.state.state.createTab as createTabState +import org.junit.Test + +class InactiveTabsControllerTest { + @Test + fun `WHEN expanded THEN notify filtered card`() { + val filter: (TabSessionState) -> Boolean = { !it.content.private } + val store = BrowserStore( + BrowserState( + tabs = listOf( + createTabState("https://mozilla.org", id = "1"), + createTabState("https://firefox.com", id = "2"), + createTabState("https://getpocket.com", id = "3", private = true) + ) + ) + ) + val tray: TabsTray = mockk(relaxed = true) + val tabsSlot = slot() + val controller = InactiveTabsController(store, filter, tray) + + controller.updateCardExpansion(true) + + verify { tray.updateTabs(capture(tabsSlot)) } + + assertEquals(2, tabsSlot.captured.list.size) + assertEquals("1", tabsSlot.captured.list.first().id) + } +}