diff --git a/app/src/main/java/org/mozilla/fenix/components/history/PagedHistoryProvider.kt b/app/src/main/java/org/mozilla/fenix/components/history/PagedHistoryProvider.kt new file mode 100644 index 000000000..7acaec215 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/components/history/PagedHistoryProvider.kt @@ -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) -> 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) -> 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) + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryAdapter.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryAdapter.kt index b744d4629..e7c7c67bd 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryAdapter.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryAdapter.kt @@ -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) { - val items: List +class HistoryAdapter( + private val historyInteractor: HistoryInteractor +) : PagedListAdapter(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() - 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>.adapterItemsForRange(range: Range): List { - return this[range]?.let { historyItems -> - val items = mutableListOf() - 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) { return calendar.time } - } -} -class HistoryAdapter(private val historyInteractor: HistoryInteractor) : - RecyclerView.Adapter() { - private var historyList: HistoryList = HistoryList(emptyList()) - private var mode: HistoryState.Mode = HistoryState.Mode.Normal - var selected = listOf() - - fun updateData(items: List, 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() { + 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 } } } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSource.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSource.kt new file mode 100644 index 000000000..7c8c48289 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSource.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 + +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() { + override fun getKey(item: HistoryItem): Int = item.id + + override fun loadInitial( + params: LoadInitialParams, + callback: LoadInitialCallback + ) { + historyProvider.getHistory(INITIAL_OFFSET, params.requestedLoadSize.toLong()) { history -> + val items = history.mapIndexed(transformVisitInfoToHistoryItem(INITIAL_OFFSET.toInt())) + callback.onResult(items) + } + } + + override fun loadAfter(params: LoadParams, callback: LoadCallback) { + historyProvider.getHistory(params.key.toLong(), params.requestedLoadSize.toLong()) { history -> + val items = history.mapIndexed(transformVisitInfoToHistoryItem(params.key)) + callback.onResult(items) + } + } + + override fun loadBefore(params: LoadParams, callback: LoadCallback) {} + + 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) + } + } + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSourceFactory.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSourceFactory.kt new file mode 100644 index 000000000..e7d1bca7b --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryDataSourceFactory.kt @@ -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() { + + val datasourceLiveData = MutableLiveData() + + override fun create(): DataSource { + val datasource = HistoryDataSource(historyProvider) + datasourceLiveData.postValue(datasource) + return datasource + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt index 37d0f9c35..aa3325496 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryFragment.kt @@ -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) { + fun deleteHistoryItems(items: Set) { 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, + selected: Set, 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 - } } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryInteractor.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryInteractor.kt index 15f4f5454..898eb409f 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryInteractor.kt @@ -13,26 +13,43 @@ class HistoryInteractor( private val openToBrowser: (item: HistoryItem) -> Unit, private val displayDeleteAll: () -> Unit, private val invalidateOptionsMenu: () -> Unit, - private val deleteHistoryItems: (List) -> Unit + private val deleteHistoryItems: (Set) -> 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) { + override fun onDeleteSome(items: Set) { deleteHistoryItems.invoke(items) } } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryStore.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryStore.kt index 5c55cae07..8abbe7b00 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryStore.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryStore.kt @@ -28,7 +28,6 @@ class HistoryStore(initialState: HistoryState) : */ sealed class HistoryAction : Action { data class Change(val list: List) : 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, val mode: Mode) : State { sealed class Mode { object Normal : Mode() - data class Editing(val selectedItems: List) : Mode() + data class Editing(val selectedItems: Set) : Mode() object Deleting : Mode() } } @@ -55,28 +54,21 @@ data class HistoryState(val items: List, 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 } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryView.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryView.kt index 99c418a10..eb32e2954 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryView.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryView.kt @@ -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) + fun onDeleteSome(items: Set) } /** @@ -97,18 +81,20 @@ class HistoryView( override val containerView: View? get() = container - private val historyAdapter: HistoryAdapter + val historyAdapter: HistoryAdapter private var items: List = 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() } } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryViewModel.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryViewModel.kt new file mode 100644 index 000000000..0466f098a --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryViewModel.kt @@ -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> + private val datasource: LiveData + + 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 + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryDeleteButtonViewHolder.kt b/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryDeleteButtonViewHolder.kt deleted file mode 100644 index 30f0f5d6d..000000000 --- a/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryDeleteButtonViewHolder.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryHeaderViewHolder.kt b/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryHeaderViewHolder.kt deleted file mode 100644 index fcb11b62b..000000000 --- a/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryHeaderViewHolder.kt +++ /dev/null @@ -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 - } -} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt b/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt index efd9bd104..57eb7bc7d 100644 --- a/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt +++ b/app/src/main/java/org/mozilla/fenix/library/history/viewholders/HistoryListItemViewHolder.kt @@ -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 } } diff --git a/app/src/main/res/layout/delete_history_button.xml b/app/src/main/res/layout/delete_history_button.xml deleted file mode 100644 index 04a76d35a..000000000 --- a/app/src/main/res/layout/delete_history_button.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/fragment_history.xml b/app/src/main/res/layout/fragment_history.xml index 4ec91bb45..73b71c6b0 100644 --- a/app/src/main/res/layout/fragment_history.xml +++ b/app/src/main/res/layout/fragment_history.xml @@ -2,14 +2,9 @@ - - - + android:orientation="vertical"/> diff --git a/app/src/main/res/layout/history_header.xml b/app/src/main/res/layout/history_header.xml deleted file mode 100644 index 78715b463..000000000 --- a/app/src/main/res/layout/history_header.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/history_list_item.xml b/app/src/main/res/layout/history_list_item.xml new file mode 100644 index 000000000..05e414138 --- /dev/null +++ b/app/src/main/res/layout/history_list_item.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/buildSrc/src/main/java/Dependencies.kt b/buildSrc/src/main/java/Dependencies.kt index c2e7ee047..6bab89648 100644 --- a/buildSrc/src/main/java/Dependencies.kt +++ b/buildSrc/src/main/java/Dependencies.kt @@ -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}"