For #10163 - Adds tab multiselect mode
parent
6c0be8db1d
commit
46511d6f8e
@ -0,0 +1,72 @@
|
||||
/* 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.tabtray
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import android.widget.CheckedTextView
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import mozilla.components.support.ktx.android.util.dpToPx
|
||||
import org.mozilla.fenix.R
|
||||
|
||||
internal class CollectionsAdapter(
|
||||
private val collections: Array<String>,
|
||||
private val onNewCollectionClicked: () -> Unit
|
||||
) : RecyclerView.Adapter<CollectionsAdapter.CollectionItemViewHolder>() {
|
||||
|
||||
@VisibleForTesting
|
||||
internal var checkedPosition = 1
|
||||
|
||||
class CollectionItemViewHolder(val textView: CheckedTextView) :
|
||||
RecyclerView.ViewHolder(textView)
|
||||
|
||||
override fun onCreateViewHolder(
|
||||
parent: ViewGroup,
|
||||
viewType: Int
|
||||
): CollectionItemViewHolder {
|
||||
val textView = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.collection_dialog_list_item, parent, false) as CheckedTextView
|
||||
return CollectionItemViewHolder(textView)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: CollectionItemViewHolder, position: Int) {
|
||||
if (position == 0) {
|
||||
val displayMetrics = holder.textView.context.resources.displayMetrics
|
||||
holder.textView.setPadding(NEW_COLLECTION_PADDING_START.dpToPx(displayMetrics), 0, 0, 0)
|
||||
holder.textView.compoundDrawablePadding =
|
||||
NEW_COLLECTION_DRAWABLE_PADDING.dpToPx(displayMetrics)
|
||||
holder.textView.setCompoundDrawablesWithIntrinsicBounds(
|
||||
ContextCompat.getDrawable(
|
||||
holder.textView.context,
|
||||
R.drawable.ic_new
|
||||
), null, null, null
|
||||
)
|
||||
} else {
|
||||
holder.textView.isChecked = checkedPosition == position
|
||||
}
|
||||
|
||||
holder.textView.setOnClickListener {
|
||||
if (position == 0) {
|
||||
onNewCollectionClicked()
|
||||
} else if (checkedPosition != position) {
|
||||
notifyItemChanged(position)
|
||||
notifyItemChanged(checkedPosition)
|
||||
checkedPosition = position
|
||||
}
|
||||
}
|
||||
holder.textView.text = collections[position]
|
||||
}
|
||||
|
||||
override fun getItemCount() = collections.size
|
||||
|
||||
fun getSelectedCollection() = checkedPosition - 1
|
||||
|
||||
companion object {
|
||||
private const val NEW_COLLECTION_PADDING_START = 24
|
||||
private const val NEW_COLLECTION_DRAWABLE_PADDING = 28
|
||||
}
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
/* 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.tabtray
|
||||
|
||||
import mozilla.components.browser.state.state.BrowserState
|
||||
import mozilla.components.concept.tabstray.Tab
|
||||
import mozilla.components.lib.state.Action
|
||||
import mozilla.components.lib.state.State
|
||||
import mozilla.components.lib.state.Store
|
||||
|
||||
/**
|
||||
* The [Store] for holding the [TabTrayDialogFragmentState] and
|
||||
* applying [TabTrayDialogFragmentAction]s.
|
||||
*/
|
||||
class TabTrayDialogFragmentStore(initialState: TabTrayDialogFragmentState) :
|
||||
Store<TabTrayDialogFragmentState, TabTrayDialogFragmentAction>(
|
||||
initialState,
|
||||
::tabTrayStateReducer
|
||||
)
|
||||
|
||||
/**
|
||||
* Actions to dispatch through the `TabTrayDialogFragmentStore` to modify
|
||||
* `TabTrayDialogFragmentState` through the reducer.
|
||||
*/
|
||||
sealed class TabTrayDialogFragmentAction : Action {
|
||||
data class BrowserStateChanged(val browserState: BrowserState) : TabTrayDialogFragmentAction()
|
||||
object EnterMultiSelectMode : TabTrayDialogFragmentAction()
|
||||
object ExitMultiSelectMode : TabTrayDialogFragmentAction()
|
||||
data class AddItemForCollection(val item: Tab) : TabTrayDialogFragmentAction()
|
||||
data class RemoveItemForCollection(val item: Tab) : TabTrayDialogFragmentAction()
|
||||
}
|
||||
|
||||
/**
|
||||
* The state for the Tab Tray Dialog Screen
|
||||
* @property mode Current Mode of Multiselection
|
||||
*/
|
||||
data class TabTrayDialogFragmentState(val browserState: BrowserState, val mode: Mode) : State {
|
||||
sealed class Mode {
|
||||
open val selectedItems = emptySet<Tab>()
|
||||
|
||||
object Normal : Mode()
|
||||
data class MultiSelect(override val selectedItems: Set<Tab>) : Mode()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The TabTrayDialogFragmentState Reducer.
|
||||
*/
|
||||
private fun tabTrayStateReducer(
|
||||
state: TabTrayDialogFragmentState,
|
||||
action: TabTrayDialogFragmentAction
|
||||
): TabTrayDialogFragmentState {
|
||||
return when (action) {
|
||||
is TabTrayDialogFragmentAction.BrowserStateChanged -> state.copy(browserState = action.browserState)
|
||||
is TabTrayDialogFragmentAction.AddItemForCollection ->
|
||||
state.copy(mode = TabTrayDialogFragmentState.Mode.MultiSelect(state.mode.selectedItems + action.item))
|
||||
is TabTrayDialogFragmentAction.RemoveItemForCollection -> {
|
||||
val selected = state.mode.selectedItems - action.item
|
||||
state.copy(
|
||||
mode = if (selected.isEmpty()) {
|
||||
TabTrayDialogFragmentState.Mode.Normal
|
||||
} else {
|
||||
TabTrayDialogFragmentState.Mode.MultiSelect(selected)
|
||||
}
|
||||
)
|
||||
}
|
||||
is TabTrayDialogFragmentAction.ExitMultiSelectMode -> state.copy(mode = TabTrayDialogFragmentState.Mode.Normal)
|
||||
is TabTrayDialogFragmentAction.EnterMultiSelectMode -> state.copy(
|
||||
mode = TabTrayDialogFragmentState.Mode.MultiSelect(
|
||||
setOf()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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/. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/linear_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<View
|
||||
android:id="@+id/top_divider"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="?neutralFaded" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/scroll_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:listitem="@layout/collection_dialog_list_item" />
|
||||
</ScrollView>
|
||||
|
||||
<View
|
||||
android:id="@+id/bottom_divider"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:background="?neutralFaded" />
|
||||
</LinearLayout>
|
@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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/. -->
|
||||
<CheckedTextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/add_new_collection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?android:selectableItemBackground"
|
||||
android:drawablePadding="24dp"
|
||||
android:ellipsize="marquee"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="?attr/listPreferredItemHeightSmall"
|
||||
android:paddingStart="20dp"
|
||||
android:paddingEnd="?attr/dialogPreferredPadding"
|
||||
android:text="@string/tab_tray_add_new_collection"
|
||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||
android:textColor="?primaryText"
|
||||
app:drawableStartCompat="?android:attr/listChoiceIndicatorSingle" />
|
@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 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/. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/name_header"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="26dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:text="@string/tab_tray_add_new_collection_name"
|
||||
android:textAllCaps="true"
|
||||
android:textAppearance="@style/Body16TextStyle"
|
||||
android:textColor="?secondaryText" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/collection_name"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:backgroundTint="?neutral"
|
||||
android:inputType="text"
|
||||
android:singleLine="true"
|
||||
android:textAlignment="viewStart" />
|
||||
</LinearLayout>
|
@ -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 org.mozilla.fenix.tabtray
|
||||
|
||||
import android.widget.FrameLayout
|
||||
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
|
||||
|
||||
@RunWith(FenixRobolectricTestRunner::class)
|
||||
class CollectionsAdapterTest {
|
||||
private val collectionList: Array<String> =
|
||||
arrayOf(
|
||||
"Add new collection",
|
||||
"Collection 1",
|
||||
"Collection 2"
|
||||
)
|
||||
private val onNewCollectionClicked: () -> Unit = mockk(relaxed = true)
|
||||
|
||||
@Test
|
||||
fun `getItemCount should return the correct list size`() {
|
||||
val adapter = CollectionsAdapter(collectionList, onNewCollectionClicked)
|
||||
|
||||
assertEquals(3, adapter.itemCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getSelectedCollection should account for add new collection when returning right item`() {
|
||||
val adapter = CollectionsAdapter(collectionList, onNewCollectionClicked)
|
||||
|
||||
// first collection by default
|
||||
assertEquals(1, adapter.checkedPosition)
|
||||
assertEquals(0, adapter.getSelectedCollection())
|
||||
|
||||
adapter.checkedPosition = 3
|
||||
assertEquals(2, adapter.getSelectedCollection())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creates and binds viewholder`() {
|
||||
val adapter = CollectionsAdapter(collectionList, onNewCollectionClicked)
|
||||
|
||||
val holder1 = adapter.createViewHolder(FrameLayout(testContext), 0)
|
||||
val holder2 = adapter.createViewHolder(FrameLayout(testContext), 0)
|
||||
val holder3 = adapter.createViewHolder(FrameLayout(testContext), 0)
|
||||
|
||||
adapter.bindViewHolder(holder1, 0)
|
||||
adapter.bindViewHolder(holder2, 1)
|
||||
adapter.bindViewHolder(holder3, 2)
|
||||
|
||||
assertEquals("Add new collection", holder1.textView.text)
|
||||
holder1.textView.callOnClick()
|
||||
verify {
|
||||
onNewCollectionClicked()
|
||||
}
|
||||
|
||||
assertEquals(true, holder2.textView.isChecked)
|
||||
assertEquals("Collection 1", holder2.textView.text)
|
||||
holder2.textView.callOnClick()
|
||||
assertEquals(true, holder2.textView.isChecked)
|
||||
|
||||
assertEquals(false, holder3.textView.isChecked)
|
||||
assertEquals("Collection 2", holder3.textView.text)
|
||||
holder3.textView.callOnClick()
|
||||
adapter.bindViewHolder(holder3, 2)
|
||||
assertEquals(true, holder3.textView.isChecked)
|
||||
}
|
||||
}
|
@ -0,0 +1,145 @@
|
||||
/* 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.tabtray
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import mozilla.components.browser.state.state.BrowserState
|
||||
import mozilla.components.browser.state.state.createTab
|
||||
import mozilla.components.concept.tabstray.Tab
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Test
|
||||
|
||||
class TabTrayDialogFragmentStoreTest {
|
||||
|
||||
@Test
|
||||
fun browserStateChange() = runBlocking {
|
||||
val initialState = emptyDefaultState()
|
||||
val store = TabTrayDialogFragmentStore(initialState)
|
||||
|
||||
val newBrowserState = BrowserState(
|
||||
listOf(
|
||||
createTab("https://www.mozilla.org", id = "13256")
|
||||
)
|
||||
)
|
||||
|
||||
store.dispatch(
|
||||
TabTrayDialogFragmentAction.BrowserStateChanged(
|
||||
newBrowserState
|
||||
)
|
||||
).join()
|
||||
|
||||
assertNotSame(initialState, store.state)
|
||||
assertEquals(
|
||||
store.state.browserState,
|
||||
newBrowserState
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enterMultiselectMode() = runBlocking {
|
||||
val initialState = emptyDefaultState()
|
||||
val store = TabTrayDialogFragmentStore(initialState)
|
||||
|
||||
store.dispatch(
|
||||
TabTrayDialogFragmentAction.EnterMultiSelectMode
|
||||
).join()
|
||||
|
||||
assertNotSame(initialState, store.state)
|
||||
assertEquals(
|
||||
store.state.mode,
|
||||
TabTrayDialogFragmentState.Mode.MultiSelect(setOf())
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun exitMultiselectMode() = runBlocking {
|
||||
val initialState = TabTrayDialogFragmentState(
|
||||
browserState = BrowserState(),
|
||||
mode = TabTrayDialogFragmentState.Mode.MultiSelect(setOf())
|
||||
)
|
||||
val store = TabTrayDialogFragmentStore(initialState)
|
||||
|
||||
store.dispatch(
|
||||
TabTrayDialogFragmentAction.ExitMultiSelectMode
|
||||
).join()
|
||||
|
||||
assertNotSame(initialState, store.state)
|
||||
assertEquals(
|
||||
store.state.mode,
|
||||
TabTrayDialogFragmentState.Mode.Normal
|
||||
)
|
||||
assertEquals(
|
||||
store.state.mode.selectedItems,
|
||||
setOf<Tab>()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addItemForCollection() = runBlocking {
|
||||
val initialState = emptyDefaultState()
|
||||
val store = TabTrayDialogFragmentStore(initialState)
|
||||
|
||||
val tab = Tab(id = "1234", url = "mozilla.org")
|
||||
store.dispatch(
|
||||
TabTrayDialogFragmentAction.AddItemForCollection(tab)
|
||||
).join()
|
||||
|
||||
assertNotSame(initialState, store.state)
|
||||
assertEquals(
|
||||
store.state.mode,
|
||||
TabTrayDialogFragmentState.Mode.MultiSelect(setOf(tab))
|
||||
)
|
||||
assertEquals(
|
||||
store.state.mode.selectedItems,
|
||||
setOf(tab)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeItemForCollection() = runBlocking {
|
||||
val tab = Tab(id = "1234", url = "mozilla.org")
|
||||
val secondTab = Tab(id = "12345", url = "pocket.com")
|
||||
|
||||
val initialState = TabTrayDialogFragmentState(
|
||||
browserState = BrowserState(),
|
||||
mode = TabTrayDialogFragmentState.Mode.MultiSelect(setOf(tab, secondTab))
|
||||
)
|
||||
|
||||
val store = TabTrayDialogFragmentStore(initialState)
|
||||
|
||||
store.dispatch(
|
||||
TabTrayDialogFragmentAction.RemoveItemForCollection(tab)
|
||||
).join()
|
||||
|
||||
assertNotSame(initialState, store.state)
|
||||
assertEquals(
|
||||
store.state.mode,
|
||||
TabTrayDialogFragmentState.Mode.MultiSelect(setOf(secondTab))
|
||||
)
|
||||
assertEquals(
|
||||
store.state.mode.selectedItems,
|
||||
setOf(secondTab)
|
||||
)
|
||||
|
||||
store.dispatch(
|
||||
TabTrayDialogFragmentAction.RemoveItemForCollection(secondTab)
|
||||
).join()
|
||||
|
||||
assertEquals(
|
||||
store.state.mode,
|
||||
TabTrayDialogFragmentState.Mode.Normal
|
||||
)
|
||||
assertEquals(
|
||||
store.state.mode.selectedItems,
|
||||
setOf<Tab>()
|
||||
)
|
||||
}
|
||||
|
||||
private fun emptyDefaultState(): TabTrayDialogFragmentState = TabTrayDialogFragmentState(
|
||||
browserState = BrowserState(),
|
||||
mode = TabTrayDialogFragmentState.Mode.Normal
|
||||
)
|
||||
}
|
Loading…
Reference in New Issue