Closes #25967: add classes to support multiple viewHolders

This commit is contained in:
mike a 2022-07-15 15:23:31 -07:00 committed by mergify[bot]
parent 5e15e9a6b3
commit f910fcfe76
22 changed files with 1100 additions and 1 deletions

View File

@ -10,6 +10,7 @@ import android.view.LayoutInflater
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.TextView
import androidx.annotation.VisibleForTesting
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import mozilla.components.concept.menu.MenuController
@ -28,7 +29,8 @@ class LibrarySiteItemView @JvmOverloads constructor(
defStyleRes: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) {
private val binding = LibrarySiteItemBinding.inflate(
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val binding = LibrarySiteItemBinding.inflate(
LayoutInflater.from(context),
this,
true

View File

@ -0,0 +1,113 @@
/* 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.library.history
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* The class represents the items types used by [HistoryAdapter] to populate the list.
* It contains the data for viewHolders. Subclasses match the variety of viewHolders.
*/
sealed class HistoryViewItem : Parcelable {
/**
* A class representing a regular history record in the history and synced history lists.
*
* @param data history item that will be displayed.
* @param collapsed state flag to support collapsed header feature; collapsed items will be
* filtered out from the list of displayed items.
*/
@Parcelize
data class HistoryItem(
val data: History.Regular,
val collapsed: Boolean = false
) : HistoryViewItem()
/**
* A class representing a search group (a group of history items) in the history list.
*
* @param data History group item that will be displayed.
* @param collapsed State flag to support collapsed header feature; collapsed items will be
* filtered out from the list of displayed items.
*/
@Parcelize
data class HistoryGroupItem(
val data: History.Group,
val collapsed: Boolean = false
) : HistoryViewItem()
/**
* A class representing a header in the history and synced history lists.
*
* @param title inside a time group header.
* @param timeGroup A time group associated with a Header.
* @param collapsed state flag to support collapsed header feature; collapsed items will be
* filtered out from the list of displayed items.
*/
@Parcelize
data class TimeGroupHeader(
val title: String,
val timeGroup: HistoryItemTimeGroup,
val collapsed: Boolean = false
) : HistoryViewItem()
/**
* A class representing a recently closed button in the history list.
*
* @param title of a recently closed button inside History screen.
* @param body of a recently closed button inside History screen.
*/
@Parcelize
data class RecentlyClosedItem(
val title: String,
val body: String
) : HistoryViewItem()
/**
* A class representing a synced history button in the history list.
*
* @param title of a recently closed button inside History screen.
*/
@Parcelize
data class SyncedHistoryItem(
val title: String
) : HistoryViewItem()
/**
* A class representing empty state in history and synced history screens.
*
* @param emptyMessage of an emptyView inside History screen.
*/
@Parcelize
data class EmptyHistoryItem(
val emptyMessage: String
) : HistoryViewItem()
/**
* A class representing a sign-in window inside the synced history screen.
*/
@Parcelize
object SignInHistoryItem : HistoryViewItem()
/**
* A class representing an extra space that header items have above them when they are
* not in a collapsed state.
*
* @param timeGroup A time group associated with a separator; separator relates to the time group
* of items above, not below. In case the time group is collapsed, it should be hidden with its
* time group as well, so collapsed groups wouldn't have extra spacing in between.
*/
@Parcelize
data class TimeGroupSeparatorHistoryItem(
val timeGroup: HistoryItemTimeGroup?
) : HistoryViewItem()
/**
* A class representing a space at the top of history and synced history lists.
*/
@Parcelize
object TopSeparatorHistoryItem : HistoryViewItem()
}

View File

@ -0,0 +1,36 @@
/* 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.library.history.viewholders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.HistoryListEmptyBinding
import org.mozilla.fenix.library.history.HistoryAdapter
import org.mozilla.fenix.library.history.HistoryViewItem
/**
* A view representing the empty state in history and synced history screens.
* [HistoryAdapter] is responsible for creating and populating the view.
*
* @param view that is passed down to the parent's constructor.
*/
class EmptyViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val binding = HistoryListEmptyBinding.bind(view)
/**
* Binds data to the view.
*
* @param item Data associated with the view.
*/
fun bind(item: HistoryViewItem.EmptyHistoryItem) {
binding.emptyMessage.text = item.emptyMessage
}
companion object {
const val LAYOUT_ID = R.layout.history_list_empty
}
}

View File

@ -0,0 +1,101 @@
/* 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.library.history.viewholders
import android.content.res.Resources
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.HistoryListGroupBinding
import org.mozilla.fenix.ext.hideAndDisable
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.history.HistoryAdapter
import org.mozilla.fenix.library.history.HistoryFragmentState
import org.mozilla.fenix.library.history.HistoryInteractor
import org.mozilla.fenix.library.history.HistoryViewItem
import org.mozilla.fenix.selection.SelectionHolder
import org.mozilla.fenix.library.LibrarySiteItemView
/**
* A view representing a search group (a group of history items) in the history list.
* [HistoryAdapter] is responsible for creating and populating the view.
*
* @param view that is passed down to the parent's constructor.
* @param historyInteractor Passed down to [LibrarySiteItemView], to handle selection of multiple items.
* @param selectionHolder Contains selected elements.
* @param onDeleteClicked Invokes when a delete button is pressed.
*/
class HistoryGroupViewHolder(
view: View,
private val historyInteractor: HistoryInteractor,
private val selectionHolder: SelectionHolder<History>,
private val onDeleteClicked: (Int) -> Unit
) : RecyclerView.ViewHolder(view) {
private val binding = HistoryListGroupBinding.bind(view)
init {
binding.historyGroupLayout.overflowView.apply {
setImageResource(R.drawable.ic_close)
contentDescription = view.context.getString(R.string.history_delete_item)
setOnClickListener {
onDeleteClicked.invoke(bindingAdapterPosition)
}
}
}
/**
* Binds data to the view.
*
* @param item Data associated with the view.
* @param mode is used to determine if the list is in the multiple-selection state or not.
* @param groupPendingDeletionCount is used to adjust the number of items inside a group,
* based on the number of items the user has removed from it.
*/
fun bind(
item: HistoryViewItem.HistoryGroupItem,
mode: HistoryFragmentState.Mode,
groupPendingDeletionCount: Int
) {
with(binding.historyGroupLayout) {
iconView.setImageResource(R.drawable.ic_multiple_tabs)
titleView.text = item.data.title
urlView.text = getGroupCountText(
itemSize = item.data.items.size,
pendingDeletionSize = groupPendingDeletionCount,
resources = resources
)
setSelectionInteractor(item.data, selectionHolder, historyInteractor)
changeSelected(item.data in selectionHolder.selectedItems)
if (mode is HistoryFragmentState.Mode.Editing) {
overflowView.hideAndDisable()
} else {
overflowView.showAndEnable()
}
}
}
internal fun getGroupCountText(
itemSize: Int,
pendingDeletionSize: Int,
resources: Resources
): String {
val numChildren = itemSize - pendingDeletionSize
val stringId = if (numChildren == 1) {
R.string.history_search_group_site
} else {
R.string.history_search_group_sites
}
return String.format(resources.getString(stringId), numChildren)
}
companion object {
const val LAYOUT_ID = R.layout.history_list_group
}
}

View File

@ -50,6 +50,8 @@ class HistoryListItemViewHolder(
/**
* Displays the data of the given history record.
*
* @param item Data associated with the view.
* @param timeGroup used to form headers for different time frames, like today, yesterday, etc.
* @param showTopContent enables the Recent tab button.
* @param mode switches between editing and regular modes.

View File

@ -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 org.mozilla.fenix.library.history.viewholders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.HistoryListHistoryBinding
import org.mozilla.fenix.ext.hideAndDisable
import org.mozilla.fenix.ext.showAndEnable
import org.mozilla.fenix.library.LibrarySiteItemView
import org.mozilla.fenix.library.history.History
import org.mozilla.fenix.library.history.HistoryAdapter
import org.mozilla.fenix.library.history.HistoryFragmentState
import org.mozilla.fenix.library.history.HistoryInteractor
import org.mozilla.fenix.library.history.HistoryViewItem
import org.mozilla.fenix.selection.SelectionHolder
/**
* A view representing a regular history record in the history and synced history lists.
* [HistoryAdapter] is responsible for creating and populating the view.
*
* @param view that is passed down to the parent's constructor.
* @param historyInteractor Passed down to [LibrarySiteItemView], to handle selection of multiple items.
* @param selectionHolder Contains selected elements.
* @param onDeleteClicked Invokes when a delete button is pressed.
*/
class HistoryViewHolder(
view: View,
val historyInteractor: HistoryInteractor,
val selectionHolder: SelectionHolder<History>,
private val onDeleteClicked: (Int) -> Unit
) : RecyclerView.ViewHolder(view) {
private lateinit var historyItem: HistoryViewItem.HistoryItem
val binding = HistoryListHistoryBinding.bind(view)
init {
binding.historyLayout.overflowView.apply {
setImageResource(R.drawable.ic_close)
contentDescription = view.context.getString(R.string.history_delete_item)
setOnClickListener {
onDeleteClicked.invoke(bindingAdapterPosition)
}
}
}
/**
* Binds data to the view.
*
* @param item Data associated with the view.
* @param mode is used to determine if the list is in the multiple-selection state or not.
*/
fun bind(item: HistoryViewItem.HistoryItem, mode: HistoryFragmentState.Mode) {
with(binding.historyLayout) {
titleView.text = item.data.title
urlView.text = item.data.url
setSelectionInteractor(item.data, selectionHolder, historyInteractor)
changeSelected(item.data in selectionHolder.selectedItems)
if (!::historyItem.isInitialized ||
historyItem.data.url != item.data.url
) {
loadFavicon(item.data.url)
}
if (mode is HistoryFragmentState.Mode.Editing) {
overflowView.hideAndDisable()
} else {
overflowView.showAndEnable()
}
}
historyItem = item
}
companion object {
const val LAYOUT_ID = R.layout.history_list_history
}
}

View File

@ -0,0 +1,50 @@
/* 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.library.history.viewholders
import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.RecentlyClosedNavItemBinding
import org.mozilla.fenix.library.history.HistoryAdapter
import org.mozilla.fenix.library.history.HistoryInteractor
import org.mozilla.fenix.library.history.HistoryViewItem
/**
* A view containing a recently closed button in the history list.
* [HistoryAdapter] is responsible for creating and populating the view.
*
* @param view that is passed down to the parent's constructor.
* @param historyInteractor Handles a click even on the item.
*/
class RecentlyClosedViewHolder(
view: View,
private val historyInteractor: HistoryInteractor
) : RecyclerView.ViewHolder(view) {
private val binding = RecentlyClosedNavItemBinding.bind(view)
init {
binding.root.setOnClickListener {
historyInteractor.onRecentlyClosedClicked()
}
binding.recentlyClosedNav.isVisible = true
}
/**
* Binds data to the view.
*
* @param item Data associated with the view.
*/
fun bind(item: HistoryViewItem.RecentlyClosedItem) {
binding.recentlyClosedTabsHeader.text = item.title
binding.recentlyClosedTabsDescription.text = item.body
}
companion object {
const val LAYOUT_ID = R.layout.recently_closed_nav_item
}
}

View File

@ -0,0 +1,41 @@
/* 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.library.history.viewholders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.HistoryListSignInBinding
import org.mozilla.fenix.library.history.HistoryAdapter
/**
* A view representing a sign in window inside the synced history screen.
* [HistoryAdapter] is responsible for creating and populating the view.
*
* @param view that is passed down to the parent's constructor.
* @param onSignInClicked Invokes when a signIn button is pressed.
* @param onCreateAccountClicked Invokes when a createAccount button is pressed.
*/
class SignInViewHolder(
view: View,
private val onSignInClicked: () -> Unit,
private val onCreateAccountClicked: () -> Unit
) : RecyclerView.ViewHolder(view) {
private val binding = HistoryListSignInBinding.bind(view)
init {
binding.signInButton.setOnClickListener {
onSignInClicked.invoke()
}
binding.createAccount.setOnClickListener {
onCreateAccountClicked.invoke()
}
}
companion object {
const val LAYOUT_ID = R.layout.history_list_sign_in
}
}

View File

@ -0,0 +1,49 @@
/* 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.library.history.viewholders
import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.SyncedHistoryNavItemBinding
import org.mozilla.fenix.library.history.HistoryAdapter
import org.mozilla.fenix.library.history.HistoryInteractor
import org.mozilla.fenix.library.history.HistoryViewItem
/**
* A view representing a synced history button in the history list.
* [HistoryAdapter] is responsible for creating and populating the view.
*
* @param view that is passed down to the parent's constructor.
* @param historyInteractor Handles a click even on the item.
*/
class SyncedHistoryViewHolder(
view: View,
private val historyInteractor: HistoryInteractor
) : RecyclerView.ViewHolder(view) {
private val binding = SyncedHistoryNavItemBinding.bind(view)
init {
binding.root.setOnClickListener {
historyInteractor.onSyncedHistoryClicked()
}
binding.syncedHistoryNav.isVisible = true
}
/**
* Binds data to the view.
*
* @param item Data associated with the view.
*/
fun bind(item: HistoryViewItem.SyncedHistoryItem) {
binding.syncedHistoryHeader.text = item.title
}
companion object {
const val LAYOUT_ID = R.layout.synced_history_nav_item
}
}

View File

@ -0,0 +1,23 @@
/* 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.library.history.viewholders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.library.history.HistoryAdapter
/**
* A view used as an extra space for time group items, when they are not in a collapsed state.
* [HistoryAdapter] is responsible for creating this view.
*
* @param view that is passed down to the parent's constructor.
*/
class TimeGroupSeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) {
companion object {
const val LAYOUT_ID = R.layout.history_list_time_group_separator
}
}

View File

@ -0,0 +1,50 @@
/* 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.library.history.viewholders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.HistoryListHeaderBinding
import org.mozilla.fenix.library.history.HistoryAdapter
import org.mozilla.fenix.library.history.HistoryItemTimeGroup
import org.mozilla.fenix.library.history.HistoryViewItem
/**
* A view representing a Header in the history and synced history lists.
* [HistoryAdapter] is responsible for creating and populating the view with data.
*
* @param view that is passed down to the parent's constructor.
* @param onClickListener Invokes on a click event on the viewHolder.
*/
class TimeGroupViewHolder(
view: View,
private val onClickListener: (HistoryItemTimeGroup, Boolean) -> Unit
) : RecyclerView.ViewHolder(view) {
private val binding = HistoryListHeaderBinding.bind(view)
private lateinit var item: HistoryViewItem.TimeGroupHeader
init {
binding.root.setOnClickListener {
onClickListener.invoke(item.timeGroup, item.collapsed)
}
}
/**
* Binds data to the view.
*
* @param item Data associated with the view.
*/
fun bind(item: HistoryViewItem.TimeGroupHeader) {
binding.headerTitle.text = item.title
binding.chevron.isActivated = !item.collapsed
this.item = item
}
companion object {
const val LAYOUT_ID = R.layout.history_list_header
}
}

View File

@ -0,0 +1,23 @@
/* 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.library.history.viewholders
import android.view.View
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.library.history.HistoryAdapter
import org.mozilla.fenix.R
/**
* A view used as an extra space at the top of history and synced history lists.
* [HistoryAdapter] is responsible for creating this view.
*
* @param view that is passed down to the parent's constructor.
*/
class TopSeparatorViewHolder(view: View) : RecyclerView.ViewHolder(view) {
companion object {
const val LAYOUT_ID = R.layout.history_list_top_separator
}
}

View File

@ -0,0 +1,22 @@
<?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/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="0dp">
<TextView
android:id="@+id/empty_message"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="center"
android:text="@string/history_empty_message"
android:textColor="?attr/textSecondary"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,9 @@
<?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/. -->
<org.mozilla.fenix.library.LibrarySiteItemView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/history_group_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="@dimen/library_item_height" />

View File

@ -0,0 +1,46 @@
<?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/. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:paddingEnd="4dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:background="?attr/layer1">
<TextView
android:id="@+id/header_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:fontFamily="@font/metropolis_semibold"
android:textColor="?attr/textPrimary"
android:textSize="16sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/chevron"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Header and a super long something in the end for testing" />
<ImageView
android:id="@+id/chevron"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:importantForAccessibility="no"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_chevron" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View File

@ -0,0 +1,9 @@
<?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/. -->
<org.mozilla.fenix.library.LibrarySiteItemView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/history_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="@dimen/library_item_height" />

View File

@ -0,0 +1,46 @@
<?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/. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/sign_in_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="80dp"
android:layout_marginTop="40dp"
android:gravity="center"
android:text="@string/history_sign_in_message"
android:textAppearance="@style/Body14TextStyle"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/sign_in_button"
style="@style/PositiveButton"
android:layout_marginHorizontal="60dp"
android:layout_marginTop="16dp"
android:text="@string/history_sign_in_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/sign_in_text" />
<TextView
android:id="@+id/create_account"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="64dp"
android:layout_marginTop="16dp"
android:gravity="center"
android:text="@string/history_sign_in_create_account"
android:textColor="?attr/textPrimary"
android:textSize="10sp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/sign_in_button" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,8 @@
<?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/. -->
<View xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/separator"
android:layout_width="match_parent"
android:layout_height="32dp"/>

View File

@ -0,0 +1,8 @@
<?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/. -->
<View xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/separator"
android:layout_width="match_parent"
android:layout_height="8dp"/>

View File

@ -754,6 +754,12 @@
<string name="history_synced_from_other_devices">Synced from other devices</string>
<!-- The page title for browsing history coming from other devices. -->
<string name="history_from_other_devices">From other devices</string>
<!-- The synced history sign in dialog message -->
<string name="history_sign_in_message">Sign in to see history synced from your other devices.</string>
<!-- The synced history sign in dialog button text -->
<string name="history_sign_in_button">Sign in</string>
<!-- The synced history sign in dialog create a new account link -->
<string name="history_sign_in_create_account"><![CDATA[<u>Or create a Firefox account to start syncing</u>]]></string>
<!-- Downloads -->
<!-- Text for the snackbar to confirm that multiple downloads items have been removed -->

View File

@ -0,0 +1,196 @@
package org.mozilla.fenix.library.history
import android.view.LayoutInflater
import android.view.View
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import mozilla.components.concept.storage.HistoryMetadataKey
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.HistoryListGroupBinding
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.library.history.viewholders.HistoryGroupViewHolder
@RunWith(FenixRobolectricTestRunner::class)
class HistoryGroupViewHolderTest {
private lateinit var binding: HistoryListGroupBinding
private lateinit var interactor: HistoryInteractor
private val metaDataItem = History.Metadata(
position = 0,
title = "Mozilla",
url = "https://foundation.mozilla.org",
visitedAt = 12398410293L,
historyTimeGroup = HistoryItemTimeGroup.Today,
totalViewTime = 1250,
historyMetadataKey = HistoryMetadataKey(
url = "https://foundation.mozilla.org",
searchTerm = "mozilla"
),
selected = false
)
private val historyGroupItem = HistoryViewItem.HistoryGroupItem(
data = History.Group(
position = 0,
title = "Mozilla",
visitedAt = 12398410293L,
historyTimeGroup = HistoryItemTimeGroup.Today,
items = listOf(
metaDataItem,
metaDataItem.copy(position = 1),
metaDataItem.copy(position = 2),
metaDataItem.copy(position = 3)
),
selected = false
)
)
@Before
fun setup() {
binding = HistoryListGroupBinding.inflate(LayoutInflater.from(testContext))
interactor = mockk(relaxed = true)
}
@Test
fun `GIVEN a history group item has more than one item THEN viewHolder uses text for multiple sites`() {
val viewHolder = testViewHolder()
val expectedText = String.format(testContext.resources.getString(R.string.history_search_group_sites), 5)
val actualText = viewHolder.getGroupCountText(5, 0, testContext.resources)
assertEquals(expectedText, actualText)
}
@Test
fun `GIVEN a history group item has exactly one item THEN get text for single site`() {
val viewHolder = testViewHolder()
val expectedText = String.format(testContext.resources.getString(R.string.history_search_group_site), 1)
val actualText = viewHolder.getGroupCountText(1, 0, testContext.resources)
assertEquals(expectedText, actualText)
}
@Test
fun `GIVEN a new history group item on bind THEN set the history group name and items size`() {
val viewHolder = testViewHolder()
viewHolder.bind(historyGroupItem, HistoryFragmentState.Mode.Normal, 0)
val childrenSizeExpectedText = viewHolder.getGroupCountText(
historyGroupItem.data.items.size,
0,
testContext.resources
)
assertEquals(historyGroupItem.data.title, binding.historyGroupLayout.titleView.text)
assertEquals(childrenSizeExpectedText, binding.historyGroupLayout.urlView.text)
}
@Test
fun `GIVEN pending deletion not zero THEN adjust items size`() {
val viewHolder = testViewHolder()
viewHolder.bind(historyGroupItem, HistoryFragmentState.Mode.Normal, 1)
val childrenSizeExpectedText = viewHolder.getGroupCountText(
historyGroupItem.data.items.size,
1,
testContext.resources
)
assertEquals(childrenSizeExpectedText, binding.historyGroupLayout.urlView.text)
}
@Test
fun `WHEN a history item delete icon is clicked THEN onDeleteClicked is called`() {
var isDeleteClicked = false
testViewHolder(
onDeleteClicked = { isDeleteClicked = true }
).bind(historyGroupItem, HistoryFragmentState.Mode.Normal, 0)
binding.historyGroupLayout.overflowView.performClick()
assertEquals(true, isDeleteClicked)
}
@Test
fun `WHEN a history item is clicked THEN interactor open is called`() {
testViewHolder().bind(historyGroupItem, HistoryFragmentState.Mode.Normal, 0)
binding.historyGroupLayout.performClick()
verify { interactor.open(historyGroupItem.data) }
}
@Test
fun `GIVEN selecting mode THEN delete button is not visible `() {
testViewHolder().bind(historyGroupItem, HistoryFragmentState.Mode.Editing(setOf()), 0)
assertEquals(View.INVISIBLE, binding.historyGroupLayout.overflowView.visibility)
}
@Test
fun `GIVEN normal mode THEN delete button is visible `() {
testViewHolder().bind(historyGroupItem, HistoryFragmentState.Mode.Normal, 0)
assertEquals(View.VISIBLE, binding.historyGroupLayout.overflowView.visibility)
}
@Test
fun `GIVEN editing mode WHEN item is selected THEN checkmark is visible `() {
testViewHolder(
selectedHistoryItems = setOf(historyGroupItem.data)
).bind(historyGroupItem, HistoryFragmentState.Mode.Editing(setOf(historyGroupItem.data)), 0)
assertEquals(1, binding.historyGroupLayout.binding.icon.displayedChild)
}
@Test
fun `GIVEN editing mode WHEN item is not selected THEN checkmark is not visible `() {
testViewHolder().bind(historyGroupItem, HistoryFragmentState.Mode.Editing(setOf()), 0)
assertEquals(0, binding.historyGroupLayout.binding.icon.displayedChild)
}
@Test
fun `GIVEN normal mode WHEN item is long pressed THEN interactor select is called`() {
testViewHolder().bind(historyGroupItem, HistoryFragmentState.Mode.Normal, 0)
binding.historyGroupLayout.performLongClick()
verify { interactor.select(historyGroupItem.data) }
}
@Test
fun `GIVEN editing mode and item is not selected WHEN item is clicked THEN interactor select is called`() {
testViewHolder(
selectedHistoryItems = setOf(historyGroupItem.data.copy(position = 1))
).bind(historyGroupItem, HistoryFragmentState.Mode.Normal, 0)
binding.historyGroupLayout.performClick()
verify { interactor.select(historyGroupItem.data) }
}
@Test
fun `GIVEN editing mode and item is selected WHEN item is clicked THEN interactor select is called`() {
testViewHolder(
selectedHistoryItems = setOf(historyGroupItem.data)
).bind(historyGroupItem, HistoryFragmentState.Mode.Normal, 0)
binding.historyGroupLayout.performClick()
verify { interactor.deselect(historyGroupItem.data) }
}
private fun testViewHolder(
view: View = binding.root,
historyInteractor: HistoryInteractor = interactor,
selectedHistoryItems: Set<History> = setOf(),
onDeleteClicked: (Int) -> Unit = {}
): HistoryGroupViewHolder {
return HistoryGroupViewHolder(
view = view,
historyInteractor = historyInteractor,
selectionHolder = mockk { every { selectedItems } returns selectedHistoryItems },
onDeleteClicked = onDeleteClicked
)
}
}

View File

@ -0,0 +1,176 @@
package org.mozilla.fenix.library.history
import android.view.LayoutInflater
import android.view.View
import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import io.mockk.verify
import mozilla.components.browser.icons.BrowserIcons
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.databinding.HistoryListHistoryBinding
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.library.history.viewholders.HistoryViewHolder
@RunWith(FenixRobolectricTestRunner::class)
class HistoryViewHolderTest {
private lateinit var binding: HistoryListHistoryBinding
private lateinit var interactor: HistoryInteractor
private lateinit var iconsLoader: BrowserIcons
private val historyItem = HistoryViewItem.HistoryItem(
data = History.Regular(
position = 0,
title = "Mozilla",
url = "https://foundation.mozilla.org",
visitedAt = 12398410293L,
historyTimeGroup = HistoryItemTimeGroup.Today,
selected = false
)
)
@Before
fun setup() {
binding = HistoryListHistoryBinding.inflate(LayoutInflater.from(testContext))
interactor = mockk(relaxed = true)
iconsLoader = spyk(
BrowserIcons(
testContext,
mockk(relaxed = true)
)
)
every { testContext.components.core.icons } returns iconsLoader
}
@Test
fun `GIVEN a new history item on bind THEN set the history title and url text`() {
testViewHolder().bind(historyItem, HistoryFragmentState.Mode.Normal)
assertEquals(historyItem.data.title, binding.historyLayout.titleView.text)
assertEquals(historyItem.data.url, binding.historyLayout.urlView.text)
}
@Test
fun `WHEN a new history item on bind THEN the icon is loaded`() {
testViewHolder(
selectedHistoryItems = setOf(historyItem.data)
).bind(historyItem, HistoryFragmentState.Mode.Editing(setOf(historyItem.data)))
verify { iconsLoader.loadIntoView(binding.historyLayout.iconView, historyItem.data.url) }
}
@Test
fun `WHEN the same history item on bind twice THEN the icon is not loaded again`() {
testViewHolder(
selectedHistoryItems = setOf(historyItem.data)
).apply {
bind(historyItem, HistoryFragmentState.Mode.Editing(setOf(historyItem.data)))
bind(historyItem, HistoryFragmentState.Mode.Editing(setOf(historyItem.data)))
}
verify(exactly = 1) {
iconsLoader.loadIntoView(
binding.historyLayout.iconView,
historyItem.data.url
)
}
}
@Test
fun `WHEN a history item delete icon is clicked THEN onDeleteClicked is called`() {
var isDeleteClicked = false
testViewHolder(
onDeleteClicked = { isDeleteClicked = true }
).bind(historyItem, HistoryFragmentState.Mode.Normal)
binding.historyLayout.overflowView.performClick()
assertEquals(true, isDeleteClicked)
}
@Test
fun `WHEN a history item is clicked THEN interactor open is called`() {
testViewHolder().bind(historyItem, HistoryFragmentState.Mode.Normal)
binding.historyLayout.performClick()
verify { interactor.open(historyItem.data) }
}
@Test
fun `GIVEN selecting mode THEN delete button is not visible `() {
testViewHolder().bind(historyItem, HistoryFragmentState.Mode.Editing(setOf()))
assertEquals(View.INVISIBLE, binding.historyLayout.overflowView.visibility)
}
@Test
fun `GIVEN normal mode THEN delete button is visible `() {
testViewHolder().bind(historyItem, HistoryFragmentState.Mode.Normal)
assertEquals(View.VISIBLE, binding.historyLayout.overflowView.visibility)
}
@Test
fun `GIVEN editing mode WHEN item is selected THEN checkmark is visible `() {
testViewHolder(
selectedHistoryItems = setOf(historyItem.data)
).bind(historyItem, HistoryFragmentState.Mode.Editing(setOf(historyItem.data)))
assertEquals(1, binding.historyLayout.binding.icon.displayedChild)
}
@Test
fun `GIVEN editing mode WHEN item is not selected THEN checkmark is not visible `() {
testViewHolder().bind(historyItem, HistoryFragmentState.Mode.Editing(setOf()))
assertEquals(0, binding.historyLayout.binding.icon.displayedChild)
}
@Test
fun `GIVEN normal mode WHEN item is long pressed THEN interactor select is called`() {
testViewHolder().bind(historyItem, HistoryFragmentState.Mode.Normal)
binding.historyLayout.performLongClick()
verify { interactor.select(historyItem.data) }
}
@Test
fun `GIVEN editing mode and item is not selected WHEN item is clicked THEN interactor select is called`() {
testViewHolder(
selectedHistoryItems = setOf(historyItem.data.copy(position = 1))
).bind(historyItem, HistoryFragmentState.Mode.Normal)
binding.historyLayout.performClick()
verify { interactor.select(historyItem.data) }
}
@Test
fun `GIVEN editing mode and item is selected WHEN item is clicked THEN interactor select is called`() {
testViewHolder(
selectedHistoryItems = setOf(historyItem.data)
).bind(historyItem, HistoryFragmentState.Mode.Normal)
binding.historyLayout.performClick()
verify { interactor.deselect(historyItem.data) }
}
private fun testViewHolder(
view: View = binding.root,
historyInteractor: HistoryInteractor = interactor,
selectedHistoryItems: Set<History> = setOf(),
onDeleteClicked: (Int) -> Unit = {}
): HistoryViewHolder {
return HistoryViewHolder(
view = view,
historyInteractor = historyInteractor,
selectionHolder = mockk { every { selectedItems } returns selectedHistoryItems },
onDeleteClicked = onDeleteClicked
)
}
}