From 811e3736e7e8a03ca7641db2de8ed3a79113bc85 Mon Sep 17 00:00:00 2001 From: mike a Date: Fri, 15 Jul 2022 15:23:31 -0700 Subject: [PATCH] [fenix] Closes https://github.com/mozilla-mobile/fenix/issues/25967: add classes to support multiple viewHolders --- .../mozilla/fenix/library/LibrarySiteItemView.kt | 4 +- .../fenix/library/history/HistoryViewItem.kt | 113 ++++++++++++ .../library/history/viewholders/EmptyViewHolder.kt | 36 ++++ .../history/viewholders/HistoryGroupViewHolder.kt | 101 +++++++++++ .../viewholders/HistoryListItemViewHolder.kt | 2 + .../history/viewholders/HistoryViewHolder.kt | 83 +++++++++ .../viewholders/RecentlyClosedViewHolder.kt | 50 ++++++ .../history/viewholders/SignInViewHolder.kt | 41 +++++ .../history/viewholders/SyncedHistoryViewHolder.kt | 49 ++++++ .../viewholders/TimeGroupSeparatorViewHolder.kt | 23 +++ .../history/viewholders/TimeGroupViewHolder.kt | 50 ++++++ .../history/viewholders/TopSeparatorViewHolder.kt | 23 +++ .../app/src/main/res/layout/history_list_empty.xml | 22 +++ .../app/src/main/res/layout/history_list_group.xml | 9 + .../src/main/res/layout/history_list_header.xml | 46 +++++ .../src/main/res/layout/history_list_history.xml | 9 + .../src/main/res/layout/history_list_sign_in.xml | 46 +++++ .../layout/history_list_time_group_separator.xml | 8 + .../main/res/layout/history_list_top_separator.xml | 8 + .../fenix/app/src/main/res/values/strings.xml | 6 + .../library/history/HistoryGroupViewHolderTest.kt | 196 +++++++++++++++++++++ .../fenix/library/history/HistoryViewHolderTest.kt | 176 ++++++++++++++++++ 22 files changed, 1100 insertions(+), 1 deletion(-) create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/HistoryViewItem.kt create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/EmptyViewHolder.kt create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryGroupViewHolder.kt create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryViewHolder.kt create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/RecentlyClosedViewHolder.kt create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/SignInViewHolder.kt create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/SyncedHistoryViewHolder.kt create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/TimeGroupSeparatorViewHolder.kt create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/TimeGroupViewHolder.kt create mode 100644 mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/TopSeparatorViewHolder.kt create mode 100644 mobile/android/fenix/app/src/main/res/layout/history_list_empty.xml create mode 100644 mobile/android/fenix/app/src/main/res/layout/history_list_group.xml create mode 100644 mobile/android/fenix/app/src/main/res/layout/history_list_header.xml create mode 100644 mobile/android/fenix/app/src/main/res/layout/history_list_history.xml create mode 100644 mobile/android/fenix/app/src/main/res/layout/history_list_sign_in.xml create mode 100644 mobile/android/fenix/app/src/main/res/layout/history_list_time_group_separator.xml create mode 100644 mobile/android/fenix/app/src/main/res/layout/history_list_top_separator.xml create mode 100644 mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryGroupViewHolderTest.kt create mode 100644 mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryViewHolderTest.kt diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/LibrarySiteItemView.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/LibrarySiteItemView.kt index 479ff64cd730..74e6c899a097 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/LibrarySiteItemView.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/LibrarySiteItemView.kt @@ -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 diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/HistoryViewItem.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/HistoryViewItem.kt new file mode 100644 index 000000000000..4fc807ced285 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/HistoryViewItem.kt @@ -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() +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/EmptyViewHolder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/EmptyViewHolder.kt new file mode 100644 index 000000000000..c7e40af8fca3 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/EmptyViewHolder.kt @@ -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 + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryGroupViewHolder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryGroupViewHolder.kt new file mode 100644 index 000000000000..dc6cde39bc59 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryGroupViewHolder.kt @@ -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, + 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 + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt index 8f6d42d638a3..c77e66298053 100644 --- a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt @@ -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. diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryViewHolder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryViewHolder.kt new file mode 100644 index 000000000000..d54fc21d3227 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryViewHolder.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 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, + 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 + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/RecentlyClosedViewHolder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/RecentlyClosedViewHolder.kt new file mode 100644 index 000000000000..447407bf83ab --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/RecentlyClosedViewHolder.kt @@ -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 + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/SignInViewHolder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/SignInViewHolder.kt new file mode 100644 index 000000000000..f948fd771716 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/SignInViewHolder.kt @@ -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 + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/SyncedHistoryViewHolder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/SyncedHistoryViewHolder.kt new file mode 100644 index 000000000000..6e413a9c8b1b --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/SyncedHistoryViewHolder.kt @@ -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 + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/TimeGroupSeparatorViewHolder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/TimeGroupSeparatorViewHolder.kt new file mode 100644 index 000000000000..3df1f4abbc6a --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/TimeGroupSeparatorViewHolder.kt @@ -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 + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/TimeGroupViewHolder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/TimeGroupViewHolder.kt new file mode 100644 index 000000000000..802cebed2c65 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/TimeGroupViewHolder.kt @@ -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 + } +} diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/TopSeparatorViewHolder.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/TopSeparatorViewHolder.kt new file mode 100644 index 000000000000..c00cd949a0c0 --- /dev/null +++ b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/library/history/viewholders/TopSeparatorViewHolder.kt @@ -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 + } +} diff --git a/mobile/android/fenix/app/src/main/res/layout/history_list_empty.xml b/mobile/android/fenix/app/src/main/res/layout/history_list_empty.xml new file mode 100644 index 000000000000..4250683a796a --- /dev/null +++ b/mobile/android/fenix/app/src/main/res/layout/history_list_empty.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/mobile/android/fenix/app/src/main/res/layout/history_list_group.xml b/mobile/android/fenix/app/src/main/res/layout/history_list_group.xml new file mode 100644 index 000000000000..79254db52e55 --- /dev/null +++ b/mobile/android/fenix/app/src/main/res/layout/history_list_group.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/android/fenix/app/src/main/res/layout/history_list_header.xml b/mobile/android/fenix/app/src/main/res/layout/history_list_header.xml new file mode 100644 index 000000000000..5c6546fe621b --- /dev/null +++ b/mobile/android/fenix/app/src/main/res/layout/history_list_header.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + diff --git a/mobile/android/fenix/app/src/main/res/layout/history_list_history.xml b/mobile/android/fenix/app/src/main/res/layout/history_list_history.xml new file mode 100644 index 000000000000..82b8e7b8cafd --- /dev/null +++ b/mobile/android/fenix/app/src/main/res/layout/history_list_history.xml @@ -0,0 +1,9 @@ + + + diff --git a/mobile/android/fenix/app/src/main/res/layout/history_list_sign_in.xml b/mobile/android/fenix/app/src/main/res/layout/history_list_sign_in.xml new file mode 100644 index 000000000000..e8a8111cd2fd --- /dev/null +++ b/mobile/android/fenix/app/src/main/res/layout/history_list_sign_in.xml @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/mobile/android/fenix/app/src/main/res/layout/history_list_time_group_separator.xml b/mobile/android/fenix/app/src/main/res/layout/history_list_time_group_separator.xml new file mode 100644 index 000000000000..3db1ddc7960e --- /dev/null +++ b/mobile/android/fenix/app/src/main/res/layout/history_list_time_group_separator.xml @@ -0,0 +1,8 @@ + + + diff --git a/mobile/android/fenix/app/src/main/res/layout/history_list_top_separator.xml b/mobile/android/fenix/app/src/main/res/layout/history_list_top_separator.xml new file mode 100644 index 000000000000..b40827580371 --- /dev/null +++ b/mobile/android/fenix/app/src/main/res/layout/history_list_top_separator.xml @@ -0,0 +1,8 @@ + + + diff --git a/mobile/android/fenix/app/src/main/res/values/strings.xml b/mobile/android/fenix/app/src/main/res/values/strings.xml index b41daf434809..0874c8ff571a 100644 --- a/mobile/android/fenix/app/src/main/res/values/strings.xml +++ b/mobile/android/fenix/app/src/main/res/values/strings.xml @@ -754,6 +754,12 @@ Synced from other devices From other devices + + Sign in to see history synced from your other devices. + + Sign in + + Or create a Firefox account to start syncing]]> diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryGroupViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryGroupViewHolderTest.kt new file mode 100644 index 000000000000..e431279ff57a --- /dev/null +++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryGroupViewHolderTest.kt @@ -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 = setOf(), + onDeleteClicked: (Int) -> Unit = {} + ): HistoryGroupViewHolder { + return HistoryGroupViewHolder( + view = view, + historyInteractor = historyInteractor, + selectionHolder = mockk { every { selectedItems } returns selectedHistoryItems }, + onDeleteClicked = onDeleteClicked + ) + } +} diff --git a/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryViewHolderTest.kt b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryViewHolderTest.kt new file mode 100644 index 000000000000..37d3aa4a55d5 --- /dev/null +++ b/mobile/android/fenix/app/src/test/java/org/mozilla/fenix/library/history/HistoryViewHolderTest.kt @@ -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 = setOf(), + onDeleteClicked: (Int) -> Unit = {} + ): HistoryViewHolder { + return HistoryViewHolder( + view = view, + historyInteractor = historyInteractor, + selectionHolder = mockk { every { selectedItems } returns selectedHistoryItems }, + onDeleteClicked = onDeleteClicked + ) + } +} -- 2.11.4.GIT