1
0
Fork 0

For #4137 - Adds pagination to the history view

master
ekager 2019-07-24 14:37:10 -07:00 committed by Jeff Boek
parent 0773069dad
commit 4494e40dbc
17 changed files with 525 additions and 425 deletions

View File

@ -0,0 +1,53 @@
/* 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.components.history
import kotlinx.coroutines.runBlocking
import mozilla.components.concept.storage.HistoryStorage
import mozilla.components.concept.storage.VisitInfo
import mozilla.components.concept.storage.VisitType
/**
* An Interface for providing a paginated list of [VisitInfo]
*/
interface PagedHistoryProvider {
/**
* Gets a list of [VisitInfo]
* @param offset How much to offset the list by
* @param numberOfItems How many items to fetch
* @param onComplete A callback that returns the list of [VisitInfo]
*/
fun getHistory(offset: Long, numberOfItems: Long, onComplete: (List<VisitInfo>) -> Unit)
}
// A PagedList DataSource runs on a background thread automatically.
// If we run this in our own coroutineScope it breaks the PagedList
fun HistoryStorage.createSynchronousPagedHistoryProvider(): PagedHistoryProvider {
return object : PagedHistoryProvider {
override fun getHistory(
offset: Long,
numberOfItems: Long,
onComplete: (List<VisitInfo>) -> Unit
) {
runBlocking {
val history = this@createSynchronousPagedHistoryProvider.getVisitsPaginated(
offset,
numberOfItems,
listOf(
VisitType.NOT_A_VISIT,
VisitType.DOWNLOAD,
VisitType.REDIRECT_TEMPORARY,
VisitType.RELOAD,
VisitType.EMBED,
VisitType.FRAMED_LINK,
VisitType.REDIRECT_PERMANENT
)
)
onComplete(history)
}
}
}
}

View File

@ -8,25 +8,14 @@ import android.content.Context
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
import androidx.paging.PagedListAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.mozilla.fenix.R
import org.mozilla.fenix.library.LibrarySiteItemView
import org.mozilla.fenix.library.history.viewholders.HistoryDeleteButtonViewHolder
import org.mozilla.fenix.library.history.viewholders.HistoryHeaderViewHolder
import org.mozilla.fenix.library.history.viewholders.HistoryListItemViewHolder
import java.util.Calendar
import java.util.Date
private sealed class AdapterItem {
object DeleteButton : AdapterItem()
data class SectionHeader(val range: Range) : AdapterItem()
data class Item(val item: HistoryItem) : AdapterItem()
}
private enum class Range {
enum class HistoryItemTimeGroup {
Today, ThisWeek, ThisMonth, Older;
fun humanReadable(context: Context): String = when (this) {
@ -37,54 +26,41 @@ private enum class Range {
}
}
private class HistoryList(val history: List<HistoryItem>) {
val items: List<AdapterItem>
class HistoryAdapter(
private val historyInteractor: HistoryInteractor
) : PagedListAdapter<HistoryItem, HistoryListItemViewHolder>(historyDiffCallback) {
private var mode: HistoryState.Mode = HistoryState.Mode.Normal
init {
val oneDayAgo = getDaysAgo(zero_days).time
val sevenDaysAgo = getDaysAgo(seven_days).time
val thirtyDaysAgo = getDaysAgo(thirty_days).time
override fun getItemViewType(position: Int): Int = HistoryListItemViewHolder.LAYOUT_ID
val lastWeek = LongRange(sevenDaysAgo, oneDayAgo)
val lastMonth = LongRange(thirtyDaysAgo, sevenDaysAgo)
val items = mutableListOf<AdapterItem>()
items.add(AdapterItem.DeleteButton)
val groups = history.groupBy { item ->
when {
DateUtils.isToday(item.visitedAt) -> Range.Today
lastWeek.contains(item.visitedAt) -> Range.ThisWeek
lastMonth.contains(item.visitedAt) -> Range.ThisMonth
else -> Range.Older
}
}
items.addAll(groups.adapterItemsForRange(Range.Today))
items.addAll(groups.adapterItemsForRange(Range.ThisWeek))
items.addAll(groups.adapterItemsForRange(Range.ThisMonth))
items.addAll(groups.adapterItemsForRange(Range.Older))
// No history only the delete button, so let's clear the list to show the empty text
if (items.size == 1) items.clear()
this.items = items
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): HistoryListItemViewHolder {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
return HistoryListItemViewHolder(view, historyInteractor)
}
private fun Map<Range, List<HistoryItem>>.adapterItemsForRange(range: Range): List<AdapterItem> {
return this[range]?.let { historyItems ->
val items = mutableListOf<AdapterItem>()
if (historyItems.isNotEmpty()) {
items.add(AdapterItem.SectionHeader(range))
for (item in historyItems) {
items.add(AdapterItem.Item(item))
}
}
items
} ?: listOf()
fun updateMode(mode: HistoryState.Mode) {
this.mode = mode
}
override fun onBindViewHolder(holder: HistoryListItemViewHolder, position: Int) {
val previous = if (position == 0) null else getItem(position - 1)
val current = getItem(position) ?: return
val previousHeader = previous?.let(::timeGroupForHistoryItem)
val currentHeader = timeGroupForHistoryItem(current)
val timeGroup = if (currentHeader != previousHeader) currentHeader else null
holder.bind(current, timeGroup, position == 0, mode)
}
companion object {
private const val zero_days = 0
private const val seven_days = 7
private const val thirty_days = 30
private const val zeroDays = 0
private const val sevenDays = 7
private const val thirtyDays = 30
private val oneDayAgo = getDaysAgo(zeroDays).time
private val sevenDaysAgo = getDaysAgo(sevenDays).time
private val thirtyDaysAgo = getDaysAgo(thirtyDays).time
private val lastWeekRange = LongRange(sevenDaysAgo, oneDayAgo)
private val lastMonthRange = LongRange(thirtyDaysAgo, sevenDaysAgo)
private fun getDaysAgo(daysAgo: Int): Date {
val calendar = Calendar.getInstance()
@ -92,96 +68,27 @@ private class HistoryList(val history: List<HistoryItem>) {
return calendar.time
}
}
}
class HistoryAdapter(private val historyInteractor: HistoryInteractor) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var historyList: HistoryList = HistoryList(emptyList())
private var mode: HistoryState.Mode = HistoryState.Mode.Normal
var selected = listOf<HistoryItem>()
fun updateData(items: List<HistoryItem>, mode: HistoryState.Mode) {
val diffUtil = DiffUtil.calculateDiff(
HistoryDiffUtil(
this.historyList,
HistoryList(items),
HistoryList(selected),
HistoryList((mode as? HistoryState.Mode.Editing)?.selectedItems ?: listOf()),
this.mode,
mode
)
)
this.historyList = HistoryList(items)
this.mode = mode
this.selected = if (mode is HistoryState.Mode.Editing) mode.selectedItems else listOf()
diffUtil.dispatchUpdatesTo(this)
}
private class HistoryDiffUtil(
val old: HistoryList,
val new: HistoryList,
val oldSelected: HistoryList,
val newSelected: HistoryList,
val oldMode: HistoryState.Mode,
val newMode: HistoryState.Mode
) : DiffUtil.Callback() {
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
old.items[oldItemPosition] == new.items[newItemPosition]
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val modesEqual = oldMode::class == newMode::class
val isStillSelected =
oldSelected.items.contains(old.items[oldItemPosition]) &&
newSelected.items.contains(new.items[newItemPosition])
val isStillNotSelected =
!oldSelected.items.contains(old.items[oldItemPosition]) &&
!newSelected.items.contains(new.items[newItemPosition])
return modesEqual && (isStillSelected || isStillNotSelected)
}
override fun getOldListSize(): Int = old.items.size
override fun getNewListSize(): Int = new.items.size
}
override fun getItemCount(): Int = historyList.items.size
override fun getItemViewType(position: Int): Int {
return when (historyList.items[position]) {
is AdapterItem.DeleteButton -> HistoryDeleteButtonViewHolder.LAYOUT_ID
is AdapterItem.SectionHeader -> HistoryHeaderViewHolder.LAYOUT_ID
is AdapterItem.Item -> HistoryListItemViewHolder.ID
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return if (viewType == HistoryListItemViewHolder.ID) {
val view = LibrarySiteItemView(parent.context).apply {
layoutParams = ViewGroup.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
}
HistoryListItemViewHolder(view, historyInteractor)
} else {
val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false)
when (viewType) {
HistoryDeleteButtonViewHolder.LAYOUT_ID -> HistoryDeleteButtonViewHolder(view, historyInteractor)
HistoryHeaderViewHolder.LAYOUT_ID -> HistoryHeaderViewHolder(view)
else -> throw IllegalStateException()
private fun timeGroupForHistoryItem(item: HistoryItem): HistoryItemTimeGroup {
return when {
DateUtils.isToday(item.visitedAt) -> HistoryItemTimeGroup.Today
lastWeekRange.contains(item.visitedAt) -> HistoryItemTimeGroup.ThisWeek
lastMonthRange.contains(item.visitedAt) -> HistoryItemTimeGroup.ThisMonth
else -> HistoryItemTimeGroup.Older
}
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HistoryDeleteButtonViewHolder -> holder.bind(mode)
is HistoryHeaderViewHolder -> historyList.items[position].also {
if (it is AdapterItem.SectionHeader) {
holder.bind(it.range.humanReadable(holder.itemView.context))
}
private val historyDiffCallback = object : DiffUtil.ItemCallback<HistoryItem>() {
override fun areItemsTheSame(oldItem: HistoryItem, newItem: HistoryItem): Boolean {
return oldItem == newItem
}
is HistoryListItemViewHolder -> (historyList.items[position] as AdapterItem.Item).also {
holder.bind(it.item, mode)
override fun areContentsTheSame(oldItem: HistoryItem, newItem: HistoryItem): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: HistoryItem, newItem: HistoryItem): Any? {
return newItem
}
}
}

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
import androidx.paging.ItemKeyedDataSource
import mozilla.components.concept.storage.VisitInfo
import org.mozilla.fenix.components.history.PagedHistoryProvider
import org.mozilla.fenix.ext.getHostFromUrl
class HistoryDataSource(
private val historyProvider: PagedHistoryProvider
) : ItemKeyedDataSource<Int, HistoryItem>() {
override fun getKey(item: HistoryItem): Int = item.id
override fun loadInitial(
params: LoadInitialParams<Int>,
callback: LoadInitialCallback<HistoryItem>
) {
historyProvider.getHistory(INITIAL_OFFSET, params.requestedLoadSize.toLong()) { history ->
val items = history.mapIndexed(transformVisitInfoToHistoryItem(INITIAL_OFFSET.toInt()))
callback.onResult(items)
}
}
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<HistoryItem>) {
historyProvider.getHistory(params.key.toLong(), params.requestedLoadSize.toLong()) { history ->
val items = history.mapIndexed(transformVisitInfoToHistoryItem(params.key))
callback.onResult(items)
}
}
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<HistoryItem>) {}
companion object {
private const val INITIAL_OFFSET = 0L
fun transformVisitInfoToHistoryItem(offset: Int): (id: Int, visit: VisitInfo) -> HistoryItem {
return { id, visit ->
val title = visit.title
?.takeIf(String::isNotEmpty)
?: visit.url.getHostFromUrl()
?: visit.url
HistoryItem(offset + id, title, visit.url, visit.visitTime)
}
}
}
}

View File

@ -0,0 +1,18 @@
package org.mozilla.fenix.library.history
import androidx.lifecycle.MutableLiveData
import androidx.paging.DataSource
import org.mozilla.fenix.components.history.PagedHistoryProvider
class HistoryDataSourceFactory(
private val historyProvider: PagedHistoryProvider
) : DataSource.Factory<Int, HistoryItem>() {
val datasourceLiveData = MutableLiveData<HistoryDataSource>()
override fun create(): DataSource<Int, HistoryItem> {
val datasource = HistoryDataSource(historyProvider)
datasourceLiveData.postValue(datasource)
return datasource
}
}

View File

@ -18,14 +18,12 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.Navigation
import kotlinx.android.synthetic.main.fragment_history.view.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import mozilla.components.concept.storage.VisitType
import mozilla.components.lib.state.ext.consumeFrom
import mozilla.components.support.base.feature.BackHandler
import org.mozilla.fenix.BrowserDirection
@ -34,20 +32,19 @@ import org.mozilla.fenix.HomeActivity
import org.mozilla.fenix.R
import org.mozilla.fenix.components.Components
import org.mozilla.fenix.components.StoreProvider
import org.mozilla.fenix.components.history.createSynchronousPagedHistoryProvider
import org.mozilla.fenix.components.metrics.Event
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.getHostFromUrl
import org.mozilla.fenix.ext.nav
import org.mozilla.fenix.ext.requireComponents
import org.mozilla.fenix.ext.simplifiedUrl
import org.mozilla.fenix.share.ShareTab
import java.util.concurrent.TimeUnit
@SuppressWarnings("TooManyFunctions")
class HistoryFragment : Fragment(), BackHandler {
private lateinit var historyStore: HistoryStore
private lateinit var historyView: HistoryView
private lateinit var historyInteractor: HistoryInteractor
private lateinit var viewModel: HistoryViewModel
override fun onCreateView(
inflater: LayoutInflater,
@ -70,6 +67,7 @@ class HistoryFragment : Fragment(), BackHandler {
::deleteHistoryItems
)
historyView = HistoryView(view.history_layout, historyInteractor)
return view
}
@ -80,18 +78,24 @@ class HistoryFragment : Fragment(), BackHandler {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel = HistoryViewModel(
requireComponents.core.historyStorage.createSynchronousPagedHistoryProvider()
)
requireComponents.analytics.metrics.track(Event.HistoryOpened)
setHasOptionsMenu(true)
}
fun deleteHistoryItems(items: List<HistoryItem>) {
fun deleteHistoryItems(items: Set<HistoryItem>) {
lifecycleScope.launch {
val storage = context?.components?.core?.historyStorage
for (item in items) {
context?.components?.analytics?.metrics?.track(Event.HistoryItemRemoved)
storage?.deleteVisit(item.url, item.visitedAt)
}
reloadData()
viewModel.invalidate()
historyStore.dispatch(HistoryAction.ExitDeletionMode)
}
}
@ -102,7 +106,9 @@ class HistoryFragment : Fragment(), BackHandler {
historyView.update(it)
}
lifecycleScope.launch { reloadData() }
viewModel.history.observe(this, Observer {
historyView.historyAdapter.submitList(it)
})
}
override fun onResume() {
@ -137,7 +143,7 @@ class HistoryFragment : Fragment(), BackHandler {
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.share_history_multi_select -> {
val selectedHistory =
(historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: listOf()
(historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: setOf()
when {
selectedHistory.size == 1 ->
share(selectedHistory.first().url)
@ -156,17 +162,18 @@ class HistoryFragment : Fragment(), BackHandler {
R.id.delete_history_multi_select -> {
val components = context?.applicationContext?.components!!
val selectedHistory =
(historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: listOf()
(historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: setOf()
lifecycleScope.launch(Main) {
deleteSelectedHistory(selectedHistory, components)
reloadData()
viewModel.invalidate()
historyStore.dispatch(HistoryAction.ExitDeletionMode)
}
true
}
R.id.open_history_in_new_tabs_multi_select -> {
val selectedHistory =
(historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: listOf()
(historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: setOf()
requireComponents.useCases.tabsUseCases.addTab.let { useCase ->
for (selectedItem in selectedHistory) {
requireComponents.analytics.metrics.track(Event.HistoryItemOpened)
@ -186,7 +193,7 @@ class HistoryFragment : Fragment(), BackHandler {
}
R.id.open_history_in_private_tabs_multi_select -> {
val selectedHistory =
(historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: listOf()
(historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: setOf()
requireComponents.useCases.tabsUseCases.addPrivateTab.let { useCase ->
for (selectedItem in selectedHistory) {
requireComponents.analytics.metrics.track(Event.HistoryItemOpened)
@ -230,8 +237,8 @@ class HistoryFragment : Fragment(), BackHandler {
lifecycleScope.launch {
requireComponents.analytics.metrics.track(Event.HistoryAllItemsRemoved)
requireComponents.core.historyStorage.deleteEverything()
reloadData()
launch(Dispatchers.Main) {
launch(Main) {
viewModel.invalidate()
historyStore.dispatch(HistoryAction.ExitDeletionMode)
}
}
@ -243,51 +250,8 @@ class HistoryFragment : Fragment(), BackHandler {
}
}
private suspend fun reloadData() {
val excludeTypes = listOf(
VisitType.NOT_A_VISIT,
VisitType.DOWNLOAD,
VisitType.REDIRECT_TEMPORARY,
VisitType.RELOAD,
VisitType.EMBED,
VisitType.FRAMED_LINK,
VisitType.REDIRECT_PERMANENT
)
// Until we have proper pagination, only display a limited set of history to avoid blowing up the UI.
// See https://github.com/mozilla-mobile/fenix/issues/1393
val sinceTimeMs = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(HISTORY_TIME_DAYS)
var previous: String? = null
val items = requireComponents.core.historyStorage
.getDetailedVisits(sinceTimeMs, excludeTypes = excludeTypes)
// We potentially have a large amount of visits, and multiple processing steps.
// Wrapping iterator in a sequence should make this a little memory-more efficient.
.asSequence()
.sortedByDescending { it.visitTime }
.filter {
val current = it.url.simplifiedUrl()
val isNotDuplicate = current != previous
previous = current
isNotDuplicate
}
.mapIndexed { id, item ->
val title = item.title
?.takeIf(String::isNotEmpty)
?: item.url.getHostFromUrl()
?: item.url
HistoryItem(id, title, item.url, item.visitTime)
}
.toList()
withContext(Main) {
historyStore.dispatch(HistoryAction.Change(items))
}
}
private suspend fun deleteSelectedHistory(
selected: List<HistoryItem>,
selected: Set<HistoryItem>,
components: Components = requireComponents
) {
requireComponents.analytics.metrics.track(Event.HistoryItemRemoved)
@ -306,8 +270,4 @@ class HistoryFragment : Fragment(), BackHandler {
)
nav(R.id.historyFragment, directions)
}
companion object {
private const val HISTORY_TIME_DAYS = 3L
}
}

View File

@ -13,26 +13,43 @@ class HistoryInteractor(
private val openToBrowser: (item: HistoryItem) -> Unit,
private val displayDeleteAll: () -> Unit,
private val invalidateOptionsMenu: () -> Unit,
private val deleteHistoryItems: (List<HistoryItem>) -> Unit
private val deleteHistoryItems: (Set<HistoryItem>) -> Unit
) : HistoryViewInteractor {
override fun onHistoryItemOpened(item: HistoryItem) {
openToBrowser(item)
override fun onItemPress(item: HistoryItem) {
val mode = store.state.mode
when (mode) {
is HistoryState.Mode.Normal -> openToBrowser(item)
is HistoryState.Mode.Editing -> {
val isSelected = mode.selectedItems.contains(item)
if (isSelected) {
store.dispatch(HistoryAction.RemoveItemForRemoval(item))
} else {
store.dispatch(HistoryAction.AddItemForRemoval(item))
}
}
}
}
override fun onEnterEditMode(selectedItem: HistoryItem) {
store.dispatch(HistoryAction.EnterEditMode(selectedItem))
override fun onItemLongPress(item: HistoryItem) {
val isSelected = (store.state.mode as? HistoryState.Mode.Editing)?.let {
it.selectedItems.contains(item)
} ?: false
if (isSelected) {
store.dispatch(HistoryAction.RemoveItemForRemoval(item))
} else {
store.dispatch(HistoryAction.AddItemForRemoval(item))
}
}
override fun onBackPressed() {
store.dispatch(HistoryAction.ExitEditMode)
}
override fun onItemAddedForRemoval(item: HistoryItem) {
store.dispatch(HistoryAction.AddItemForRemoval(item))
}
override fun onItemRemovedForRemoval(item: HistoryItem) {
store.dispatch(HistoryAction.RemoveItemForRemoval(item))
override fun onBackPressed(): Boolean {
return if (store.state.mode is HistoryState.Mode.Editing) {
store.dispatch(HistoryAction.ExitEditMode)
true
} else {
false
}
}
override fun onModeSwitched() {
@ -44,10 +61,10 @@ class HistoryInteractor(
}
override fun onDeleteOne(item: HistoryItem) {
deleteHistoryItems.invoke(listOf(item))
deleteHistoryItems.invoke(setOf(item))
}
override fun onDeleteSome(items: List<HistoryItem>) {
override fun onDeleteSome(items: Set<HistoryItem>) {
deleteHistoryItems.invoke(items)
}
}

View File

@ -28,7 +28,6 @@ class HistoryStore(initialState: HistoryState) :
*/
sealed class HistoryAction : Action {
data class Change(val list: List<HistoryItem>) : HistoryAction()
data class EnterEditMode(val item: HistoryItem) : HistoryAction()
object ExitEditMode : HistoryAction()
data class AddItemForRemoval(val item: HistoryItem) : HistoryAction()
data class RemoveItemForRemoval(val item: HistoryItem) : HistoryAction()
@ -44,7 +43,7 @@ sealed class HistoryAction : Action {
data class HistoryState(val items: List<HistoryItem>, val mode: Mode) : State {
sealed class Mode {
object Normal : Mode()
data class Editing(val selectedItems: List<HistoryItem>) : Mode()
data class Editing(val selectedItems: Set<HistoryItem>) : Mode()
object Deleting : Mode()
}
}
@ -55,28 +54,21 @@ data class HistoryState(val items: List<HistoryItem>, val mode: Mode) : State {
fun historyStateReducer(state: HistoryState, action: HistoryAction): HistoryState {
return when (action) {
is HistoryAction.Change -> state.copy(mode = HistoryState.Mode.Normal, items = action.list)
is HistoryAction.EnterEditMode -> state.copy(
mode = HistoryState.Mode.Editing(listOf(action.item))
)
is HistoryAction.AddItemForRemoval -> {
val mode = state.mode
if (mode is HistoryState.Mode.Editing) {
val items = mode.selectedItems + listOf(action.item)
val items = mode.selectedItems + setOf(action.item)
state.copy(mode = HistoryState.Mode.Editing(items))
} else {
state
state.copy(mode = HistoryState.Mode.Editing(setOf(action.item)))
}
}
is HistoryAction.RemoveItemForRemoval -> {
var mode = state.mode
if (mode is HistoryState.Mode.Editing) {
val items = mode.selectedItems.filter { it.id != action.item.id }
mode = if (items.isEmpty()) HistoryState.Mode.Normal else HistoryState.Mode.Editing(
items
)
state.copy(mode = mode)
val items = mode.selectedItems.minus(action.item)
state.copy(mode = HistoryState.Mode.Editing(items))
} else {
state
}

View File

@ -14,12 +14,10 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.SimpleItemAnimator
import kotlinx.android.extensions.LayoutContainer
import kotlinx.android.synthetic.main.component_history.*
import kotlinx.android.synthetic.main.component_history.view.*
import kotlinx.android.synthetic.main.delete_history_button.*
import mozilla.components.support.base.feature.BackHandler
import org.mozilla.fenix.R
import org.mozilla.fenix.ext.asActivity
@ -31,33 +29,19 @@ import org.mozilla.fenix.ext.getColorResFromAttr
*/
interface HistoryViewInteractor {
/**
* Called whenever a history item is tapped to open that history entry in the browser
* @param item the history item to open in browser
* Called when a user taps a history item
*/
fun onHistoryItemOpened(item: HistoryItem)
fun onItemPress(item: HistoryItem)
/**
* Called when a history item is long pressed and edit mode is launched
* @param selectedItem the history item to start selected for deletion in edit mode
* Called when a user long clicks a user
*/
fun onEnterEditMode(selectedItem: HistoryItem)
fun onItemLongPress(item: HistoryItem)
/**
* Called on backpressed to exit edit mode
*/
fun onBackPressed()
/**
* Called when a history item is tapped in edit mode and added for removal
* @param item the history item to add to selected items for deletion in edit mode
*/
fun onItemAddedForRemoval(item: HistoryItem)
/**
* Called when a selected history item is tapped in edit mode and removed from removal
* @param item the history item to remove from the selected items for deletion in edit mode
*/
fun onItemRemovedForRemoval(item: HistoryItem)
fun onBackPressed(): Boolean
/**
* Called when the mode is switched so we can invalidate the menu
@ -79,7 +63,7 @@ interface HistoryViewInteractor {
* Called when multiple history items are deleted
* @param items the history items to delete
*/
fun onDeleteSome(items: List<HistoryItem>)
fun onDeleteSome(items: Set<HistoryItem>)
}
/**
@ -97,18 +81,20 @@ class HistoryView(
override val containerView: View?
get() = container
private val historyAdapter: HistoryAdapter
val historyAdapter: HistoryAdapter
private var items: List<HistoryItem> = listOf()
private val context = container.context
var mode: HistoryState.Mode = HistoryState.Mode.Normal
private set
private val activity = context?.asActivity()
private val layoutManager = LinearLayoutManager(container.context)
init {
historyAdapter = HistoryAdapter(interactor)
view.history_list.apply {
historyAdapter = HistoryAdapter(interactor)
layoutManager = this@HistoryView.layoutManager
adapter = historyAdapter
layoutManager = LinearLayoutManager(container.context)
(itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false
}
}
@ -117,17 +103,33 @@ class HistoryView(
if (state.mode is HistoryState.Mode.Deleting) View.VISIBLE else View.GONE
if (state.mode != mode) {
mode = state.mode
interactor.onModeSwitched()
historyAdapter.updateMode(state.mode)
val oldMode = mode
if (oldMode is HistoryState.Mode.Editing) {
oldMode.selectedItems.forEach {
historyAdapter.notifyItemChanged(it.id)
}
}
}
(view.history_list.adapter as HistoryAdapter).updateData(state.items, state.mode)
(state.mode as? HistoryState.Mode.Editing)?.also {
val oldMode = (mode as? HistoryState.Mode.Editing)
val unselectedItems = oldMode?.selectedItems?.minus(it.selectedItems) ?: setOf()
it.selectedItems.union(unselectedItems).forEach { item ->
historyAdapter.notifyItemChanged(item.id)
}
}
items = state.items
when (val mode = mode) {
is HistoryState.Mode.Normal -> setUIForNormalMode(items.isEmpty())
when (val mode = state.mode) {
is HistoryState.Mode.Normal -> setUIForNormalMode()
is HistoryState.Mode.Editing -> setUIForSelectingMode(mode.selectedItems.size)
}
mode = state.mode
}
private fun setUIForSelectingMode(selectedItemSize: Int) {
@ -139,10 +141,10 @@ class HistoryView(
)
}
private fun setUIForNormalMode(isEmpty: Boolean) {
private fun setUIForNormalMode() {
activity?.title = context.getString(R.string.library_history)
delete_history_button?.isVisible = !isEmpty
history_empty_view.isVisible = isEmpty
// history_list?.isVisible = !isEmpty
// history_empty_view.isVisible = isEmpty
setToolbarColors(
context!!.getColorResFromAttr(R.attr.primaryText),
context.getColorResFromAttr(R.attr.foundation)
@ -186,15 +188,6 @@ class HistoryView(
}
override fun onBackPressed(): Boolean {
return when (mode) {
is HistoryState.Mode.Editing -> {
mode = HistoryState.Mode.Normal
historyAdapter.updateData(items, mode)
setUIForNormalMode(items.isEmpty())
interactor.onBackPressed()
true
}
else -> false
}
return interactor.onBackPressed()
}
}

View File

@ -0,0 +1,31 @@
/* 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 androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.paging.PagedList
import androidx.paging.LivePagedListBuilder
import org.mozilla.fenix.components.history.PagedHistoryProvider
class HistoryViewModel(historyProvider: PagedHistoryProvider) : ViewModel() {
var history: LiveData<PagedList<HistoryItem>>
private val datasource: LiveData<HistoryDataSource>
init {
val historyDataSourceFactory = HistoryDataSourceFactory(historyProvider)
datasource = historyDataSourceFactory.datasourceLiveData
history = LivePagedListBuilder(historyDataSourceFactory, PAGE_SIZE).build()
}
fun invalidate() {
datasource.value?.invalidate()
}
companion object {
private const val PAGE_SIZE = 25
}
}

View File

@ -1,51 +0,0 @@
/* 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 kotlinx.android.synthetic.main.delete_history_button.view.*
import org.mozilla.fenix.R
import org.mozilla.fenix.library.history.HistoryInteractor
import org.mozilla.fenix.library.history.HistoryState
class HistoryDeleteButtonViewHolder(
view: View,
historyInteractor: HistoryInteractor
) : RecyclerView.ViewHolder(view) {
private var mode: HistoryState.Mode? = null
private val buttonView = view.delete_history_button
init {
buttonView.setOnClickListener {
mode?.also {
when (it) {
is HistoryState.Mode.Normal -> historyInteractor.onDeleteAll()
is HistoryState.Mode.Editing -> historyInteractor.onDeleteSome(it.selectedItems)
}
}
}
}
fun bind(mode: HistoryState.Mode) {
this.mode = mode
buttonView.run {
val isDeleting = mode is HistoryState.Mode.Deleting
if (isDeleting || mode is HistoryState.Mode.Editing && mode.selectedItems.isNotEmpty()) {
isEnabled = false
alpha = DISABLED_ALPHA
} else {
isEnabled = true
alpha = 1f
}
}
}
companion object {
const val DISABLED_ALPHA = 0.4f
const val LAYOUT_ID = R.layout.delete_history_button
}
}

View File

@ -1,24 +0,0 @@
/* 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 kotlinx.android.synthetic.main.history_header.view.*
import org.mozilla.fenix.R
class HistoryHeaderViewHolder(
view: View
) : RecyclerView.ViewHolder(view) {
private val title = view.history_header_title
fun bind(title: String) {
this.title.text = title
}
companion object {
const val LAYOUT_ID = R.layout.history_header
}
}

View File

@ -4,19 +4,36 @@
package org.mozilla.fenix.library.history.viewholders
import android.view.View
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import kotlinx.android.synthetic.main.history_list_item.view.*
import mozilla.components.browser.menu.BrowserMenu
import org.mozilla.fenix.library.LibrarySiteItemView
import org.mozilla.fenix.R
import org.mozilla.fenix.ThemeManager
import org.mozilla.fenix.ext.components
import org.mozilla.fenix.ext.loadIntoView
import org.mozilla.fenix.library.history.HistoryInteractor
import org.mozilla.fenix.library.history.HistoryItem
import org.mozilla.fenix.library.history.HistoryItemMenu
import org.mozilla.fenix.library.history.HistoryItemTimeGroup
import org.mozilla.fenix.library.history.HistoryState
class HistoryListItemViewHolder(
val view: LibrarySiteItemView,
private val view: View,
private val historyInteractor: HistoryInteractor
) : RecyclerView.ViewHolder(view) {
private val layout = view.history_layout
private val favicon = view.history_favicon
private val title = view.history_title
private val url = view.history_url
private val menuButton = view.history_item_overflow
private val headerWrapper = view.header_wrapper
private val headerTitle = view.header_title
private val deleteButtonWrapper = view.delete_button_wrapper
private val deleteButton = view.delete_button
private var item: HistoryItem? = null
private lateinit var historyMenu: HistoryItemMenu
private var mode: HistoryState.Mode = HistoryState.Mode.Normal
@ -24,41 +41,122 @@ class HistoryListItemViewHolder(
init {
setupMenu()
view.setOnLongClickListener {
layout.setOnLongClickListener {
item?.apply {
historyInteractor.onEnterEditMode(this)
historyInteractor.onItemLongPress(this)
}
true
}
view.overflowView.setOnClickListener {
menuButton.setOnClickListener {
historyMenu.menuBuilder.build(view.context).show(
anchor = it,
orientation = BrowserMenu.Orientation.DOWN
)
}
view.displayAs(LibrarySiteItemView.ItemType.SITE)
itemView.history_layout.setOnClickListener {
item?.also(historyInteractor::onItemPress)
}
deleteButton.setOnClickListener {
mode?.also {
when (it) {
is HistoryState.Mode.Normal -> historyInteractor.onDeleteAll()
is HistoryState.Mode.Editing -> historyInteractor.onDeleteSome(it.selectedItems)
}
}
}
}
fun bind(item: HistoryItem, mode: HistoryState.Mode) {
fun bind(
item: HistoryItem,
timeGroup: HistoryItemTimeGroup?,
showDeletebutton: Boolean,
mode: HistoryState.Mode
) {
this.item = item
this.mode = mode
view.titleView.text = item.title
view.urlView.text = item.url
title.text = item.title
url.text = item.url
val selected = mode is HistoryState.Mode.Editing && mode.selectedItems.contains(item)
toggleDeleteButton(showDeletebutton, mode)
setClickListeners(item, selected)
val headerText = timeGroup?.let { it.humanReadable(view.context) }
toggleHeader(headerText)
view.changeSelected(selected)
view.loadFavicon(item.url)
val selected = toggleSelected(mode, item)
if (mode is HistoryState.Mode.Editing) {
val backgroundTint =
if (selected) {
ThemeManager.resolveAttribute(R.attr.accentHighContrast, itemView.context)
} else {
ThemeManager.resolveAttribute(R.attr.neutral, itemView.context)
}
val backgroundTintList =
ContextCompat.getColorStateList(itemView.context, backgroundTint)
favicon.backgroundTintList = backgroundTintList
if (selected) {
favicon.setImageResource(R.drawable.mozac_ic_check)
} else {
updateFavIcon(item.url)
}
} else {
val backgroundTint = ThemeManager.resolveAttribute(R.attr.neutral, itemView.context)
val backgroundTintList =
ContextCompat.getColorStateList(itemView.context, backgroundTint)
favicon.backgroundTintList = backgroundTintList
updateFavIcon(item.url)
}
}
private fun toggleSelected(
mode: HistoryState.Mode,
item: HistoryItem
): Boolean {
return when (mode) {
is HistoryState.Mode.Editing -> mode.selectedItems.contains(item)
else -> false
}
}
private fun toggleHeader(text: String?) {
text?.also {
headerWrapper.visibility = View.VISIBLE
headerTitle.text = it
} ?: run {
headerWrapper.visibility = View.GONE
}
}
private fun toggleDeleteButton(
showDeletebutton: Boolean,
mode: HistoryState.Mode
) {
if (showDeletebutton) {
deleteButtonWrapper.visibility = View.VISIBLE
deleteButton.run {
val isDeleting = mode is HistoryState.Mode.Deleting
if (isDeleting || mode is HistoryState.Mode.Editing && mode.selectedItems.isNotEmpty()) {
isEnabled = false
alpha = DELETE_BUTTON_DISABLED_ALPHA
} else {
isEnabled = true
alpha = 1f
}
}
} else {
deleteButtonWrapper.visibility = View.GONE
}
}
private fun setupMenu() {
historyMenu = HistoryItemMenu(view.context) {
this.historyMenu = HistoryItemMenu(itemView.context) {
when (it) {
is HistoryItemMenu.Item.Delete -> {
item?.apply { historyInteractor.onDeleteOne(this) }
@ -67,20 +165,12 @@ class HistoryListItemViewHolder(
}
}
private fun setClickListeners(
item: HistoryItem,
selected: Boolean
) {
view.setOnClickListener {
when {
mode == HistoryState.Mode.Normal -> historyInteractor.onHistoryItemOpened(item)
selected -> historyInteractor.onItemRemovedForRemoval(item)
else -> historyInteractor.onItemAddedForRemoval(item)
}
}
private fun updateFavIcon(url: String) {
favicon.context.components.core.icons.loadIntoView(favicon, url)
}
companion object {
val ID = LibrarySiteItemView.ItemType.SITE.ordinal
const val DELETE_BUTTON_DISABLED_ALPHA = 0.4f
const val LAYOUT_ID = R.layout.history_list_item
}
}

View File

@ -1,17 +0,0 @@
<?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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/delete_history_button"
style="@style/ThemeIndependentMaterialGreyButtonDestructive"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/history_delete_all"
app:rippleColor="?secondaryText" />
</FrameLayout>

View File

@ -2,14 +2,9 @@
<!-- 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.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/history_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="org.mozilla.fenix.library.history.HistoryFragment">
<LinearLayout
android:id="@+id/history_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"/>
</androidx.core.widget.NestedScrollView>
android:orientation="vertical"/>

View File

@ -1,19 +0,0 @@
<?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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="20dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp">
<TextView
android:id="@+id/history_header_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="17sp"
android:textColor="?primaryText"/>
</FrameLayout>

View File

@ -0,0 +1,105 @@
<?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:app="http://schemas.android.com/apk/res-auto"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:orientation="vertical">
<FrameLayout
android:id="@+id/delete_button_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:visibility="gone">
<com.google.android.material.button.MaterialButton
android:id="@+id/delete_button"
style="@style/ThemeIndependentMaterialGreyButtonDestructive"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/history_delete_all"
app:rippleColor="?secondaryText" />
</FrameLayout>
<FrameLayout
android:id="@+id/header_wrapper"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="20dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="8dp"
android:visibility="gone">
<TextView
android:id="@+id/header_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="17sp"
android:textColor="?primaryText"/>
</FrameLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/history_layout"
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:padding="4dp"
android:paddingStart="20dp"
android:paddingEnd="0dp">
<ImageButton
android:id="@+id/history_item_overflow"
android:layout_width="@dimen/glyph_button_width"
android:layout_height="@dimen/glyph_button_height"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/content_description_history_menu"
android:src="@drawable/ic_menu"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/history_favicon"
android:layout_width="@dimen/history_favicon_width_height"
android:layout_height="@dimen/history_favicon_width_height"
android:background="@drawable/favicon_background"
android:padding="10dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/history_url"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:ellipsize="end"
android:singleLine="true"
android:textAlignment="viewStart"
android:textColor="?secondaryText"
android:textSize="12sp"
app:layout_constraintEnd_toStartOf="@id/history_item_overflow"
app:layout_constraintStart_toEndOf="@id/history_favicon"
app:layout_constraintTop_toBottomOf="@id/history_title" />
<TextView
android:id="@+id/history_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
android:ellipsize="end"
android:singleLine="true"
android:textAlignment="viewStart"
android:textColor="?primaryText"
android:textSize="18sp"
android:layout_marginTop="2dp"
app:layout_constraintEnd_toStartOf="@id/history_item_overflow"
app:layout_constraintStart_toEndOf="@id/history_favicon"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View File

@ -28,7 +28,7 @@ object Versions {
const val androidx_testing = "1.1.0-alpha08"
const val androidx_test_ext = "1.0.0"
const val androidx_core = "1.1.0-rc02"
const val androidx_paging = "2.0.0"
const val androidx_paging = "2.1.0"
const val androidx_transition = "1.1.0"
const val androidx_work = "2.0.1"
const val google_material = "1.1.0-alpha07"
@ -159,7 +159,7 @@ object Deps {
const val androidx_lifecycle_viewmodel = "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.androidx_lifecycle}"
const val androidx_lifecycle_runtime = "androidx.lifecycle:lifecycle-runtime-ktx:${Versions.androidx_lifecycle}"
const val androidx_lifecycle_viewmodel_ss = "androidx.lifecycle:lifecycle-viewmodel-savedstate:${Versions.androidx_lifecycle_savedstate}"
const val androidx_paging = "androidx.paging:paging-runtime:${Versions.androidx_paging}"
const val androidx_paging = "androidx.paging:paging-runtime-ktx:${Versions.androidx_paging}"
const val androidx_preference = "androidx.preference:preference-ktx:${Versions.androidx_preference}"
const val androidx_safeargs = "androidx.navigation:navigation-safe-args-gradle-plugin:${Versions.androidx_navigation}"
const val androidx_navigation_fragment = "androidx.navigation:navigation-fragment-ktx:${Versions.androidx_navigation}"