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 42e481287..5911c3224 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 @@ -10,7 +10,6 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView -import io.reactivex.Observer import org.mozilla.fenix.R import org.mozilla.fenix.library.history.viewholders.HistoryDeleteButtonViewHolder import org.mozilla.fenix.library.history.viewholders.HistoryHeaderViewHolder @@ -94,9 +93,8 @@ private class HistoryList(val history: List) { } } -class HistoryAdapter( - private val actionEmitter: Observer -) : AdapterWithJob() { +class HistoryAdapter(private val historyInteractor: HistoryInteractor) : + AdapterWithJob() { private var historyList: HistoryList = HistoryList(emptyList()) private var mode: HistoryState.Mode = HistoryState.Mode.Normal var selected = listOf() @@ -160,9 +158,16 @@ class HistoryAdapter( val view = LayoutInflater.from(parent.context).inflate(viewType, parent, false) return when (viewType) { - HistoryDeleteButtonViewHolder.LAYOUT_ID -> HistoryDeleteButtonViewHolder(view, actionEmitter) + HistoryDeleteButtonViewHolder.LAYOUT_ID -> HistoryDeleteButtonViewHolder( + view, + historyInteractor + ) HistoryHeaderViewHolder.LAYOUT_ID -> HistoryHeaderViewHolder(view) - HistoryListItemViewHolder.LAYOUT_ID -> HistoryListItemViewHolder(view, actionEmitter, adapterJob) + HistoryListItemViewHolder.LAYOUT_ID -> HistoryListItemViewHolder( + view, + historyInteractor, + adapterJob + ) else -> throw IllegalStateException() } } diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryComponent.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryComponent.kt deleted file mode 100644 index d8d05a5c2..000000000 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryComponent.kt +++ /dev/null @@ -1,107 +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 - -import android.view.ViewGroup -import org.mozilla.fenix.mvi.ViewState -import org.mozilla.fenix.mvi.Change -import org.mozilla.fenix.mvi.Action -import org.mozilla.fenix.mvi.ActionBusFactory -import org.mozilla.fenix.mvi.UIComponent -import org.mozilla.fenix.mvi.UIComponentViewModelBase -import org.mozilla.fenix.mvi.UIComponentViewModelProvider -import org.mozilla.fenix.test.Mockable - -data class HistoryItem(val id: Int, val title: String, val url: String, val visitedAt: Long) - -@Mockable -class HistoryComponent( - private val container: ViewGroup, - bus: ActionBusFactory, - viewModelProvider: UIComponentViewModelProvider -) : - UIComponent( - bus.getManagedEmitter(HistoryAction::class.java), - bus.getSafeManagedObservable(HistoryChange::class.java), - viewModelProvider - ) { - - override fun initView() = HistoryUIView(container, actionEmitter, changesObservable) - - init { - bind() - } -} - -data class HistoryState(val items: List, val mode: Mode) : ViewState { - sealed class Mode { - object Normal : Mode() - data class Editing(val selectedItems: List) : Mode() - object Deleting : Mode() - } -} - -sealed class HistoryAction : Action { - data class Open(val item: HistoryItem) : HistoryAction() - data class EnterEditMode(val item: HistoryItem) : HistoryAction() - object BackPressed : HistoryAction() - data class AddItemForRemoval(val item: HistoryItem) : HistoryAction() - data class RemoveItemForRemoval(val item: HistoryItem) : HistoryAction() - object SwitchMode : HistoryAction() - - sealed class Delete : HistoryAction() { - object All : Delete() - data class One(val item: HistoryItem) : Delete() - data class Some(val items: List) : Delete() - } -} - -sealed class HistoryChange : Change { - data class Change(val list: List) : HistoryChange() - data class EnterEditMode(val item: HistoryItem) : HistoryChange() - object ExitEditMode : HistoryChange() - data class AddItemForRemoval(val item: HistoryItem) : HistoryChange() - data class RemoveItemForRemoval(val item: HistoryItem) : HistoryChange() - object EnterDeletionMode : HistoryChange() - object ExitDeletionMode : HistoryChange() -} - -class HistoryViewModel( - initialState: HistoryState -) : UIComponentViewModelBase(initialState, reducer) { - companion object { - fun create() = HistoryViewModel(HistoryState(emptyList(), HistoryState.Mode.Normal)) - val reducer: (HistoryState, HistoryChange) -> HistoryState = { state, change -> - when (change) { - is HistoryChange.Change -> state.copy(mode = HistoryState.Mode.Normal, items = change.list) - is HistoryChange.EnterEditMode -> state.copy(mode = HistoryState.Mode.Editing(listOf(change.item))) - is HistoryChange.AddItemForRemoval -> { - val mode = state.mode - if (mode is HistoryState.Mode.Editing) { - val items = mode.selectedItems + listOf(change.item) - state.copy(mode = mode.copy(selectedItems = items)) - } else { - state - } - } - is HistoryChange.RemoveItemForRemoval -> { - var mode = state.mode - - if (mode is HistoryState.Mode.Editing) { - val items = mode.selectedItems.filter { it.id != change.item.id } - mode = if (items.isEmpty()) HistoryState.Mode.Normal else HistoryState.Mode.Editing(items) - - state.copy(mode = mode) - } else { - state - } - } - is HistoryChange.ExitEditMode -> state.copy(mode = HistoryState.Mode.Normal) - is HistoryChange.EnterDeletionMode -> state.copy(mode = HistoryState.Mode.Deleting) - is HistoryChange.ExitDeletionMode -> state.copy(mode = HistoryState.Mode.Normal) - } - } - } -} 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 46f8f1ff6..914a5ff1d 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 @@ -19,6 +19,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.whenStarted import androidx.navigation.Navigation import kotlinx.android.synthetic.main.fragment_history.view.* import kotlinx.coroutines.Dispatchers @@ -26,46 +27,56 @@ 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.observe import mozilla.components.support.base.feature.BackHandler import org.mozilla.fenix.BrowserDirection import org.mozilla.fenix.BrowsingModeManager -import org.mozilla.fenix.FenixViewModelProvider 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.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.mvi.ActionBusFactory -import org.mozilla.fenix.mvi.getAutoDisposeObservable -import org.mozilla.fenix.mvi.getManagedEmitter import org.mozilla.fenix.share.ShareTab import java.util.concurrent.TimeUnit @SuppressWarnings("TooManyFunctions") class HistoryFragment : Fragment(), BackHandler { - - private lateinit var historyComponent: HistoryComponent - private val navigation by lazy { Navigation.findNavController(requireView()) } + private lateinit var historyStore: HistoryStore + private lateinit var historyView: HistoryView + private lateinit var historyInteractor: HistoryInteractor override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? = inflater - .inflate(R.layout.fragment_history, container, false).also { view -> - historyComponent = HistoryComponent( - view.history_layout, - ActionBusFactory.get(this), - FenixViewModelProvider.create( - this, - HistoryViewModel::class.java, - HistoryViewModel.Companion::create + ): View? { + val view = inflater.inflate(R.layout.fragment_history, container, false) + historyStore = StoreProvider.get( + this, + HistoryStore( + HistoryState( + items = listOf(), mode = HistoryState.Mode.Normal ) ) - } + ) + historyInteractor = HistoryInteractor( + historyStore, + ::openItem, + ::displayDeleteAllDialog, + ::invalidateOptionsMenu, + ::deleteHistoryItems + ) + historyView = HistoryView(view.history_layout, historyInteractor) + return view + } + + private fun invalidateOptionsMenu() { + activity?.invalidateOptionsMenu() + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -74,16 +85,28 @@ class HistoryFragment : Fragment(), BackHandler { setHasOptionsMenu(true) } + fun deleteHistoryItems(items: List) { + lifecycleScope.launch { + val storage = context?.components?.core?.historyStorage + for (item in items) { + storage?.deleteVisit(item.url, item.visitedAt) + } + reloadData() + } + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - lifecycleScope.launch { reloadData() } - } + historyStore.observe(view) { + viewLifecycleOwner.lifecycleScope.launch { + whenStarted { + historyView.update(it) + } + } + } - override fun onStart() { - super.onStart() - getAutoDisposeObservable() - .subscribe(this::handleNewHistoryAction) + lifecycleScope.launch { reloadData() } } override fun onResume() { @@ -95,7 +118,7 @@ class HistoryFragment : Fragment(), BackHandler { } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { - val mode = (historyComponent.uiView as HistoryUIView).mode + val mode = historyStore.state.mode when (mode) { HistoryState.Mode.Normal -> R.menu.library_menu @@ -117,7 +140,8 @@ class HistoryFragment : Fragment(), BackHandler { override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { R.id.share_history_multi_select -> { - val selectedHistory = (historyComponent.uiView as HistoryUIView).getSelected() + val selectedHistory = + (historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: listOf() when { selectedHistory.size == 1 -> share(selectedHistory.first().url) @@ -135,7 +159,8 @@ class HistoryFragment : Fragment(), BackHandler { } R.id.delete_history_multi_select -> { val components = context?.applicationContext?.components!! - val selectedHistory = (historyComponent.uiView as HistoryUIView).getSelected() + val selectedHistory = + (historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: listOf() lifecycleScope.launch(Main) { deleteSelectedHistory(selectedHistory, components) @@ -144,7 +169,8 @@ class HistoryFragment : Fragment(), BackHandler { true } R.id.open_history_in_new_tabs_multi_select -> { - val selectedHistory = (historyComponent.uiView as HistoryUIView).getSelected() + val selectedHistory = + (historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: listOf() requireComponents.useCases.tabsUseCases.addTab.let { useCase -> for (selectedItem in selectedHistory) { useCase.invoke(selectedItem.url) @@ -155,11 +181,15 @@ class HistoryFragment : Fragment(), BackHandler { browsingModeManager.mode = BrowsingModeManager.Mode.Normal supportActionBar?.hide() } - nav(R.id.historyFragment, HistoryFragmentDirections.actionHistoryFragmentToHomeFragment()) + nav( + R.id.historyFragment, + HistoryFragmentDirections.actionHistoryFragmentToHomeFragment() + ) true } R.id.open_history_in_private_tabs_multi_select -> { - val selectedHistory = (historyComponent.uiView as HistoryUIView).getSelected() + val selectedHistory = + (historyStore.state.mode as? HistoryState.Mode.Editing)?.selectedItems ?: listOf() requireComponents.useCases.tabsUseCases.addPrivateTab.let { useCase -> for (selectedItem in selectedHistory) { useCase.invoke(selectedItem.url) @@ -170,47 +200,18 @@ class HistoryFragment : Fragment(), BackHandler { browsingModeManager.mode = BrowsingModeManager.Mode.Private supportActionBar?.hide() } - nav(R.id.historyFragment, HistoryFragmentDirections.actionHistoryFragmentToHomeFragment()) + nav( + R.id.historyFragment, + HistoryFragmentDirections.actionHistoryFragmentToHomeFragment() + ) true } else -> super.onOptionsItemSelected(item) } - override fun onBackPressed(): Boolean = (historyComponent.uiView as HistoryUIView).onBackPressed() + override fun onBackPressed(): Boolean = historyView.onBackPressed() - private fun handleNewHistoryAction(action: HistoryAction) { - when (action) { - is HistoryAction.Open -> - openItem(action.item) - is HistoryAction.EnterEditMode -> - emitChange { HistoryChange.EnterEditMode(action.item) } - is HistoryAction.AddItemForRemoval -> - emitChange { HistoryChange.AddItemForRemoval(action.item) } - is HistoryAction.RemoveItemForRemoval -> - emitChange { HistoryChange.RemoveItemForRemoval(action.item) } - is HistoryAction.BackPressed -> - emitChange { HistoryChange.ExitEditMode } - is HistoryAction.Delete.All -> - displayDeleteAllDialog() - is HistoryAction.Delete.One -> lifecycleScope.launch { - requireComponents.core - .historyStorage - .deleteVisit(action.item.url, action.item.visitedAt) - reloadData() - } - is HistoryAction.Delete.Some -> lifecycleScope.launch { - val storage = requireComponents.core.historyStorage - for (item in action.items) { - storage.deleteVisit(item.url, item.visitedAt) - } - reloadData() - } - is HistoryAction.SwitchMode -> - activity?.invalidateOptionsMenu() - } - } - - private fun openItem(item: HistoryItem) { + fun openItem(item: HistoryItem) { requireComponents.analytics.metrics.track(Event.HistoryItemOpened) (activity as HomeActivity).openToBrowserAndLoad( searchTermOrURL = item.url, @@ -219,7 +220,7 @@ class HistoryFragment : Fragment(), BackHandler { ) } - private fun displayDeleteAllDialog() { + fun displayDeleteAllDialog() { activity?.let { activity -> AlertDialog.Builder(activity).apply { setMessage(R.string.history_delete_all_dialog) @@ -227,13 +228,13 @@ class HistoryFragment : Fragment(), BackHandler { dialog.cancel() } setPositiveButton(R.string.history_clear_dialog) { dialog: DialogInterface, _ -> - emitChange { HistoryChange.EnterDeletionMode } + historyStore.dispatch(HistoryAction.EnterDeletionMode) lifecycleScope.launch { requireComponents.analytics.metrics.track(Event.HistoryAllItemsRemoved) requireComponents.core.historyStorage.deleteEverything() reloadData() launch(Dispatchers.Main) { - emitChange { HistoryChange.ExitDeletionMode } + historyStore.dispatch(HistoryAction.ExitDeletionMode) } } @@ -275,7 +276,7 @@ class HistoryFragment : Fragment(), BackHandler { .toList() withContext(Main) { - emitChange { HistoryChange.Change(items) } + historyStore.dispatch(HistoryAction.Change(items)) } } @@ -300,10 +301,6 @@ class HistoryFragment : Fragment(), BackHandler { nav(R.id.historyFragment, directions) } - private inline fun emitChange(producer: () -> HistoryChange) { - getManagedEmitter().onNext(producer()) - } - 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 new file mode 100644 index 000000000..15f4f5454 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryInteractor.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.library.history + +/** + * Interactor for the history screen + * Provides implementations for the HistoryViewInteractor + */ +class HistoryInteractor( + private val store: HistoryStore, + private val openToBrowser: (item: HistoryItem) -> Unit, + private val displayDeleteAll: () -> Unit, + private val invalidateOptionsMenu: () -> Unit, + private val deleteHistoryItems: (List) -> Unit +) : HistoryViewInteractor { + override fun onHistoryItemOpened(item: HistoryItem) { + openToBrowser(item) + } + + override fun onEnterEditMode(selectedItem: HistoryItem) { + store.dispatch(HistoryAction.EnterEditMode(selectedItem)) + } + + 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 onModeSwitched() { + invalidateOptionsMenu.invoke() + } + + override fun onDeleteAll() { + displayDeleteAll.invoke() + } + + override fun onDeleteOne(item: HistoryItem) { + deleteHistoryItems.invoke(listOf(item)) + } + + override fun onDeleteSome(items: List) { + 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 new file mode 100644 index 000000000..5c55cae07 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryStore.kt @@ -0,0 +1,88 @@ +/* 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 mozilla.components.lib.state.Action +import mozilla.components.lib.state.State +import mozilla.components.lib.state.Store + +/** + * Class representing a history entry + * @property id Unique id of the history item + * @property title Title of the history item + * @property url URL of the history item + * @property visitedAt Timestamp of when this history item was visited + */ +data class HistoryItem(val id: Int, val title: String, val url: String, val visitedAt: Long) + +/** + * The [Store] for holding the [HistoryState] and applying [HistoryAction]s. + */ +class HistoryStore(initialState: HistoryState) : + Store(initialState, ::historyStateReducer) + +/** + * Actions to dispatch through the `HistoryStore` to modify `HistoryState` through the reducer. + */ +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() + object EnterDeletionMode : HistoryAction() + object ExitDeletionMode : HistoryAction() +} + +/** + * The state for the History Screen + * @property items List of HistoryItem to display + * @property mode Current Mode of History + */ +data class HistoryState(val items: List, val mode: Mode) : State { + sealed class Mode { + object Normal : Mode() + data class Editing(val selectedItems: List) : Mode() + object Deleting : Mode() + } +} + +/** + * The HistoryState Reducer. + */ +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) + state.copy(mode = HistoryState.Mode.Editing(items)) + } else { + state + } + } + 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) + } else { + state + } + } + is HistoryAction.ExitEditMode -> state.copy(mode = HistoryState.Mode.Normal) + is HistoryAction.EnterDeletionMode -> state.copy(mode = HistoryState.Mode.Deleting) + is HistoryAction.ExitDeletionMode -> state.copy(mode = HistoryState.Mode.Normal) + } +} diff --git a/app/src/main/java/org/mozilla/fenix/library/history/HistoryUIView.kt b/app/src/main/java/org/mozilla/fenix/library/history/HistoryUIView.kt deleted file mode 100644 index b32ecfa84..000000000 --- a/app/src/main/java/org/mozilla/fenix/library/history/HistoryUIView.kt +++ /dev/null @@ -1,102 +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 - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.core.view.isVisible -import androidx.recyclerview.widget.LinearLayoutManager -import io.reactivex.Observable -import io.reactivex.Observer -import io.reactivex.functions.Consumer -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.getColorIntFromAttr -import org.mozilla.fenix.library.LibraryPageUIView - -class HistoryUIView( - container: ViewGroup, - actionEmitter: Observer, - changesObservable: Observable -) : - LibraryPageUIView(container, actionEmitter, changesObservable), - BackHandler { - - var mode: HistoryState.Mode = HistoryState.Mode.Normal - private set - - private val historyAdapter: HistoryAdapter - private var items: List = listOf() - - fun getSelected(): List = historyAdapter.selected - - override val view: ConstraintLayout = LayoutInflater.from(container.context) - .inflate(R.layout.component_history, container, true) - .findViewById(R.id.history_wrapper) - - init { - view.history_list.apply { - historyAdapter = HistoryAdapter(actionEmitter) - adapter = historyAdapter - layoutManager = LinearLayoutManager(container.context) - } - } - - override fun updateView() = Consumer { - view.progress_bar.visibility = if (it.mode is HistoryState.Mode.Deleting) View.VISIBLE else View.GONE - - if (it.mode != mode) { - mode = it.mode - actionEmitter.onNext(HistoryAction.SwitchMode) - } - - (view.history_list.adapter as HistoryAdapter).updateData(it.items, it.mode) - - items = it.items - when (val modeCopy = mode) { - is HistoryState.Mode.Normal -> setUIForNormalMode(items.isEmpty()) - is HistoryState.Mode.Editing -> setUIForSelectingMode(modeCopy) - } - } - - private fun setUIForSelectingMode( - mode: HistoryState.Mode.Editing - ) { - activity?.title = - context.getString(R.string.history_multi_select_title, mode.selectedItems.size) - setToolbarColors( - R.color.white_color, - R.attr.accentHighContrast.getColorIntFromAttr(context!!) - ) - } - - private fun setUIForNormalMode(isEmpty: Boolean) { - activity?.title = context.getString(R.string.library_history) - delete_history_button?.isVisible = !isEmpty - history_empty_view.isVisible = isEmpty - setToolbarColors( - R.attr.primaryText.getColorIntFromAttr(context!!), - R.attr.foundation.getColorIntFromAttr(context) - ) - } - - override fun onBackPressed(): Boolean { - return when (mode) { - is HistoryState.Mode.Editing -> { - mode = HistoryState.Mode.Normal - historyAdapter.updateData(items, mode) - setUIForNormalMode(items.isEmpty()) - actionEmitter.onNext(HistoryAction.BackPressed) - true - } - else -> false - } - } -} 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 new file mode 100644 index 000000000..af9bc2aa2 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/library/history/HistoryView.kt @@ -0,0 +1,200 @@ +/* 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.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +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 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 +import org.mozilla.fenix.ext.getColorIntFromAttr + +/** + * Interface for the HistoryViewInteractor. This interface is implemented by objects that want + * to respond to user interaction on the HistoryView + */ +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 + */ + fun onHistoryItemOpened(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 + */ + fun onEnterEditMode(selectedItem: 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) + + /** + * Called when the mode is switched so we can invalidate the menu + */ + fun onModeSwitched() + + /** + * Called when delete all is tapped + */ + fun onDeleteAll() + + /** + * Called when one history item is deleted + * @param item the history item to delete + */ + fun onDeleteOne(item: HistoryItem) + + /** + * Called when multiple history items are deleted + * @param items the history items to delete + */ + fun onDeleteSome(items: List) +} + +/** + * View that contains and configures the History List + */ +class HistoryView( + private val container: ViewGroup, + val interactor: HistoryInteractor +) : LayoutContainer, BackHandler { + + val view: ConstraintLayout = LayoutInflater.from(container.context) + .inflate(R.layout.component_history, container, true) + .findViewById(R.id.history_wrapper) + + override val containerView: View? + get() = container + + private 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() + + init { + view.history_list.apply { + historyAdapter = HistoryAdapter(interactor) + adapter = historyAdapter + layoutManager = LinearLayoutManager(container.context) + } + } + + fun update(state: HistoryState) { + view.progress_bar.visibility = + if (state.mode is HistoryState.Mode.Deleting) View.VISIBLE else View.GONE + + if (state.mode != mode) { + mode = state.mode + interactor.onModeSwitched() + } + + (view.history_list.adapter as HistoryAdapter).updateData(state.items, state.mode) + + items = state.items + when (val mode = mode) { + is HistoryState.Mode.Normal -> setUIForNormalMode(items.isEmpty()) + is HistoryState.Mode.Editing -> setUIForSelectingMode(mode.selectedItems.size) + } + } + + private fun setUIForSelectingMode(selectedItemSize: Int) { + activity?.title = + context.getString(R.string.history_multi_select_title, selectedItemSize) + setToolbarColors( + R.color.white_color, + R.attr.accentHighContrast.getColorIntFromAttr(context!!) + ) + } + + private fun setUIForNormalMode(isEmpty: Boolean) { + activity?.title = context.getString(R.string.library_history) + delete_history_button?.isVisible = !isEmpty + history_empty_view.isVisible = isEmpty + setToolbarColors( + R.attr.primaryText.getColorIntFromAttr(context!!), + R.attr.foundation.getColorIntFromAttr(context) + ) + } + + private fun setToolbarColors(foreground: Int, background: Int) { + val toolbar = (activity as AppCompatActivity).findViewById(R.id.navigationToolbar) + val colorFilter = PorterDuffColorFilter( + ContextCompat.getColor(context, foreground), + PorterDuff.Mode.SRC_IN + ) + toolbar.setBackgroundColor(ContextCompat.getColor(context, background)) + toolbar.setTitleTextColor(ContextCompat.getColor(context, foreground)) + + themeToolbar( + toolbar, foreground, + background, colorFilter + ) + } + + private fun themeToolbar( + toolbar: Toolbar, + textColor: Int, + backgroundColor: Int, + colorFilter: PorterDuffColorFilter? = null + ) { + toolbar.setTitleTextColor(ContextCompat.getColor(context!!, textColor)) + toolbar.setBackgroundColor(ContextCompat.getColor(context, backgroundColor)) + + if (colorFilter == null) { + return + } + + toolbar.overflowIcon?.colorFilter = colorFilter + (0 until toolbar.childCount).forEach { + when (val item = toolbar.getChildAt(it)) { + is ImageButton -> item.drawable.colorFilter = colorFilter + } + } + } + + 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 + } + } +} 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 index 2e14e7eb7..30f0f5d6d 100644 --- 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 @@ -6,15 +6,14 @@ package org.mozilla.fenix.library.history.viewholders import android.view.View import androidx.recyclerview.widget.RecyclerView -import io.reactivex.Observer import kotlinx.android.synthetic.main.delete_history_button.view.* import org.mozilla.fenix.R -import org.mozilla.fenix.library.history.HistoryAction +import org.mozilla.fenix.library.history.HistoryInteractor import org.mozilla.fenix.library.history.HistoryState class HistoryDeleteButtonViewHolder( view: View, - private val actionEmitter: Observer + historyInteractor: HistoryInteractor ) : RecyclerView.ViewHolder(view) { private var mode: HistoryState.Mode? = null private val buttonView = view.delete_history_button @@ -22,13 +21,10 @@ class HistoryDeleteButtonViewHolder( init { buttonView.setOnClickListener { mode?.also { - val action = when (it) { - is HistoryState.Mode.Normal -> HistoryAction.Delete.All - is HistoryState.Mode.Editing -> HistoryAction.Delete.Some(it.selectedItems) - is HistoryState.Mode.Deleting -> null - } ?: return@also - - actionEmitter.onNext(action) + when (it) { + is HistoryState.Mode.Normal -> historyInteractor.onDeleteAll() + is HistoryState.Mode.Editing -> historyInteractor.onDeleteSome(it.selectedItems) + } } } } 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 93434116a..443af9df5 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 @@ -8,7 +8,6 @@ import android.view.View import android.widget.CompoundButton import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView -import io.reactivex.Observer import kotlinx.android.synthetic.main.history_list_item.view.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -19,7 +18,7 @@ import mozilla.components.browser.menu.BrowserMenu import org.mozilla.fenix.R import org.mozilla.fenix.ThemeManager import org.mozilla.fenix.ext.components -import org.mozilla.fenix.library.history.HistoryAction +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.HistoryState @@ -27,7 +26,7 @@ import kotlin.coroutines.CoroutineContext class HistoryListItemViewHolder( view: View, - private val actionEmitter: Observer, + private val historyInteractor: HistoryInteractor, val job: Job ) : RecyclerView.ViewHolder(view), CoroutineScope { @@ -48,13 +47,11 @@ class HistoryListItemViewHolder( } item?.apply { - val action = if (isChecked) { - HistoryAction.AddItemForRemoval(this) + if (isChecked) { + historyInteractor.onItemAddedForRemoval(this) } else { - HistoryAction.RemoveItemForRemoval(this) + historyInteractor.onItemRemovedForRemoval(this) } - - actionEmitter.onNext(action) } } @@ -63,7 +60,7 @@ class HistoryListItemViewHolder( view.setOnLongClickListener { item?.apply { - actionEmitter.onNext(HistoryAction.EnterEditMode(this)) + historyInteractor.onEnterEditMode(this) } true @@ -72,7 +69,8 @@ class HistoryListItemViewHolder( menuButton.setOnClickListener { historyMenu.menuBuilder.build(view.context).show( anchor = it, - orientation = BrowserMenu.Orientation.DOWN) + orientation = BrowserMenu.Orientation.DOWN + ) } } @@ -97,7 +95,8 @@ class HistoryListItemViewHolder( } else { ThemeManager.resolveAttribute(R.attr.neutral, itemView.context) } - val backgroundTintList = ContextCompat.getColorStateList(itemView.context, backgroundTint) + val backgroundTintList = + ContextCompat.getColorStateList(itemView.context, backgroundTint) favicon.backgroundTintList = backgroundTintList if (selected) { @@ -107,7 +106,8 @@ class HistoryListItemViewHolder( } } else { val backgroundTint = ThemeManager.resolveAttribute(R.attr.neutral, itemView.context) - val backgroundTintList = ContextCompat.getColorStateList(itemView.context, backgroundTint) + val backgroundTintList = + ContextCompat.getColorStateList(itemView.context, backgroundTint) favicon.backgroundTintList = backgroundTintList updateFavIcon(item.url) } @@ -117,7 +117,7 @@ class HistoryListItemViewHolder( this.historyMenu = HistoryItemMenu(itemView.context) { when (it) { is HistoryItemMenu.Item.Delete -> { - item?.apply { actionEmitter.onNext(HistoryAction.Delete.One(this)) } + item?.apply { historyInteractor.onDeleteOne(this) } } } } @@ -139,11 +139,13 @@ class HistoryListItemViewHolder( ) { itemView.history_layout.setOnClickListener { if (mode == HistoryState.Mode.Normal) { - actionEmitter.onNext(HistoryAction.Open(item)) + historyInteractor.onHistoryItemOpened(item) } else { - if (selected) actionEmitter.onNext(HistoryAction.RemoveItemForRemoval(item)) else actionEmitter.onNext( - HistoryAction.AddItemForRemoval(item) - ) + if (selected) { + historyInteractor.onItemRemovedForRemoval(item) + } else { + historyInteractor.onItemAddedForRemoval(item) + } } } } diff --git a/app/src/test/java/org/mozilla/fenix/library/history/HistoryInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/library/history/HistoryInteractorTest.kt new file mode 100644 index 000000000..056780a1f --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/library/history/HistoryInteractorTest.kt @@ -0,0 +1,129 @@ +/* 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 io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Test + +class HistoryInteractorTest { + + @Test + fun onHistoryItemOpened() { + var historyItemReceived: HistoryItem? = null + val historyItem = HistoryItem(0, "title", "url", 0.toLong()) + val interactor = HistoryInteractor( + mockk(), + { historyItemReceived = it }, + mockk(), + mockk(), + mockk() + ) + interactor.onHistoryItemOpened(historyItem) + assertEquals(historyItem, historyItemReceived) + } + + @Test + fun onEnterEditMode() { + val store: HistoryStore = mockk(relaxed = true) + val newHistoryItem: HistoryItem = mockk(relaxed = true) + val interactor = + HistoryInteractor(store, mockk(), mockk(), mockk(), mockk()) + interactor.onEnterEditMode(newHistoryItem) + verify { store.dispatch(HistoryAction.EnterEditMode(newHistoryItem)) } + } + + @Test + fun onBackPressed() { + val store: HistoryStore = mockk(relaxed = true) + val interactor = + HistoryInteractor(store, mockk(), mockk(), mockk(), mockk()) + interactor.onBackPressed() + verify { store.dispatch(HistoryAction.ExitEditMode) } + } + + @Test + fun onItemAddedForRemoval() { + val store: HistoryStore = mockk(relaxed = true) + val newHistoryItem: HistoryItem = mockk(relaxed = true) + + val interactor = + HistoryInteractor(store, mockk(), mockk(), mockk(), mockk()) + interactor.onItemAddedForRemoval(newHistoryItem) + verify { store.dispatch(HistoryAction.AddItemForRemoval(newHistoryItem)) } + } + + @Test + fun onItemRemovedForRemoval() { + val store: HistoryStore = mockk(relaxed = true) + val newHistoryItem: HistoryItem = mockk(relaxed = true) + val interactor = + HistoryInteractor(store, mockk(), mockk(), mockk(), mockk()) + interactor.onItemRemovedForRemoval(newHistoryItem) + verify { store.dispatch(HistoryAction.RemoveItemForRemoval(newHistoryItem)) } + } + + @Test + fun onModeSwitched() { + var menuInvalidated = false + val interactor = HistoryInteractor( + mockk(), + mockk(), + mockk(), + { menuInvalidated = true }, + mockk() + ) + interactor.onModeSwitched() + assertEquals(true, menuInvalidated) + } + + @Test + fun onDeleteAll() { + var deleteAllDialogShown = false + val interactor = HistoryInteractor( + mockk(), + mockk(), + { deleteAllDialogShown = true }, + mockk(), + mockk() + ) + interactor.onDeleteAll() + assertEquals(true, deleteAllDialogShown) + } + + @Test + fun onDeleteOne() { + var itemsToDelete: List? = null + val historyItem = HistoryItem(0, "title", "url", 0.toLong()) + val interactor = + HistoryInteractor( + mockk(), + mockk(), + mockk(), + mockk(), + { itemsToDelete = it } + ) + interactor.onDeleteOne(historyItem) + assertEquals(itemsToDelete, listOf(historyItem)) + } + + @Test + fun onDeleteSome() { + var itemsToDelete: List? = null + val historyItem = HistoryItem(0, "title", "url", 0.toLong()) + val newHistoryItem = HistoryItem(1, "title", "url", 0.toLong()) + val interactor = + HistoryInteractor( + mockk(), + mockk(), + mockk(), + mockk(), + { itemsToDelete = it } + ) + interactor.onDeleteSome(listOf(historyItem, newHistoryItem)) + assertEquals(itemsToDelete, listOf(historyItem, newHistoryItem)) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/library/history/HistoryStoreTest.kt b/app/src/test/java/org/mozilla/fenix/library/history/HistoryStoreTest.kt new file mode 100644 index 000000000..ee55f06f0 --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/library/history/HistoryStoreTest.kt @@ -0,0 +1,73 @@ +/* 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 kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Test + +class HistoryStoreTest { + private val historyItem = HistoryItem(0, "title", "url", 0.toLong()) + private val newHistoryItem = HistoryItem(1, "title", "url", 0.toLong()) + + @Test + fun enterEditMode() = runBlocking { + val initialState = emptyDefaultState() + val store = HistoryStore(initialState) + + store.dispatch(HistoryAction.EnterEditMode(historyItem)).join() + assertNotSame(initialState, store.state) + assertEquals(store.state.mode, HistoryState.Mode.Editing(listOf(historyItem))) + } + + @Test + fun exitEditMode() = runBlocking { + val initialState = oneItemEditState() + val store = HistoryStore(initialState) + + store.dispatch(HistoryAction.ExitEditMode).join() + assertNotSame(initialState, store.state) + assertEquals(store.state.mode, HistoryState.Mode.Normal) + } + + @Test + fun itemAddedForRemoval() = runBlocking { + val initialState = oneItemEditState() + val store = HistoryStore(initialState) + + store.dispatch(HistoryAction.AddItemForRemoval(newHistoryItem)).join() + assertNotSame(initialState, store.state) + assertEquals( + store.state.mode, + HistoryState.Mode.Editing(listOf(historyItem, newHistoryItem)) + ) + } + + @Test + fun removeItemForRemoval() = runBlocking { + val initialState = twoItemEditState() + val store = HistoryStore(initialState) + + store.dispatch(HistoryAction.RemoveItemForRemoval(newHistoryItem)).join() + assertNotSame(initialState, store.state) + assertEquals(store.state.mode, HistoryState.Mode.Editing(listOf(historyItem))) + } + + private fun emptyDefaultState(): HistoryState = HistoryState( + items = listOf(), + mode = HistoryState.Mode.Normal + ) + + private fun oneItemEditState(): HistoryState = HistoryState( + items = listOf(), + mode = HistoryState.Mode.Editing(listOf(historyItem)) + ) + + private fun twoItemEditState(): HistoryState = HistoryState( + items = listOf(), + mode = HistoryState.Mode.Editing(listOf(historyItem, newHistoryItem)) + ) +} \ No newline at end of file diff --git a/app/src/test/java/org/mozilla/fenix/library/history/HistoryViewModelTest.kt b/app/src/test/java/org/mozilla/fenix/library/history/HistoryViewModelTest.kt deleted file mode 100644 index c51abfcfb..000000000 --- a/app/src/test/java/org/mozilla/fenix/library/history/HistoryViewModelTest.kt +++ /dev/null @@ -1,95 +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 - -import io.mockk.MockKAnnotations -import io.reactivex.Observer -import io.reactivex.observers.TestObserver -import org.junit.Before -import org.junit.Test -import org.mozilla.fenix.TestUtils.bus -import org.mozilla.fenix.TestUtils.owner -import org.mozilla.fenix.TestUtils.setRxSchedulers -import org.mozilla.fenix.mvi.getManagedEmitter - -class HistoryViewModelTest { - - private lateinit var historyViewModel: HistoryViewModel - private lateinit var historyObserver: TestObserver - private lateinit var emitter: Observer - - @Before - fun setup() { - MockKAnnotations.init(this) - setRxSchedulers() - - historyViewModel = HistoryViewModel.create() - historyObserver = historyViewModel.state.test() - bus.getSafeManagedObservable(HistoryChange::class.java) - .subscribe(historyViewModel.changes::onNext) - - emitter = owner.getManagedEmitter() - } - - @Test - fun `select two items for removal, then deselect one, then select it again`() { - val historyItem = HistoryItem(1, "Mozilla", "http://mozilla.org", 0) - val historyItem2 = HistoryItem(2, "Mozilla", "http://mozilla.org", 0) - - emitter.onNext(HistoryChange.Change(listOf(historyItem, historyItem2))) - emitter.onNext(HistoryChange.EnterEditMode(historyItem)) - emitter.onNext(HistoryChange.AddItemForRemoval(historyItem2)) - emitter.onNext(HistoryChange.RemoveItemForRemoval(historyItem)) - emitter.onNext(HistoryChange.AddItemForRemoval(historyItem)) - emitter.onNext(HistoryChange.ExitEditMode) - - historyObserver.assertSubscribed().awaitCount(7).assertNoErrors() - .assertValues( - HistoryState(listOf(), HistoryState.Mode.Normal), - HistoryState(listOf(historyItem, historyItem2), HistoryState.Mode.Normal), - HistoryState(listOf(historyItem, historyItem2), HistoryState.Mode.Editing(listOf(historyItem))), - HistoryState(listOf(historyItem, historyItem2), HistoryState.Mode.Editing(listOf(historyItem, historyItem2))), - HistoryState(listOf(historyItem, historyItem2), HistoryState.Mode.Editing(listOf(historyItem2))), - HistoryState(listOf(historyItem, historyItem2), HistoryState.Mode.Editing(listOf(historyItem2, historyItem))), - HistoryState(listOf(historyItem, historyItem2), HistoryState.Mode.Normal) - ) - } - @Test - fun `deselecting all items triggers normal mode`() { - val historyItem = HistoryItem(123, "Mozilla", "http://mozilla.org", 0) - - emitter.onNext(HistoryChange.Change(listOf(historyItem))) - emitter.onNext(HistoryChange.EnterEditMode(historyItem)) - emitter.onNext(HistoryChange.RemoveItemForRemoval(historyItem)) - historyObserver.assertSubscribed().awaitCount(6).assertNoErrors() - .assertValues( - HistoryState(listOf(), HistoryState.Mode.Normal), - HistoryState(listOf(historyItem), HistoryState.Mode.Normal), - HistoryState(listOf(historyItem), HistoryState.Mode.Editing(listOf(historyItem))), - HistoryState(listOf(historyItem), HistoryState.Mode.Normal) - ) - } - - @Test - fun `try making changes when not in edit mode`() { - val historyItems = listOf( - HistoryItem(1337, "Reddit", "http://reddit.com", 0), - HistoryItem(31337, "Haxor", "http://leethaxor.com", 0) - ) - - emitter.onNext(HistoryChange.Change(historyItems)) - emitter.onNext(HistoryChange.AddItemForRemoval(historyItems[0])) - emitter.onNext(HistoryChange.EnterEditMode(historyItems[0])) - emitter.onNext(HistoryChange.ExitEditMode) - - historyObserver.assertSubscribed().awaitCount(4).assertNoErrors() - .assertValues( - HistoryState(listOf(), HistoryState.Mode.Normal), - HistoryState(historyItems, HistoryState.Mode.Normal), - HistoryState(historyItems, HistoryState.Mode.Editing(listOf(historyItems[0]))), - HistoryState(historyItems, HistoryState.Mode.Normal) - ) - } -}