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