diff --git a/app/src/main/java/org/mozilla/fenix/collections/CollectionsDialog.kt b/app/src/main/java/org/mozilla/fenix/collections/CollectionsDialog.kt new file mode 100644 index 000000000..a5c6a8ba8 --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/collections/CollectionsDialog.kt @@ -0,0 +1,135 @@ +/* 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.collections + +import android.content.Context +import android.view.LayoutInflater +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.feature.tab.collections.TabCollection +import mozilla.components.support.ktx.android.view.showKeyboard +import org.mozilla.fenix.R +import org.mozilla.fenix.components.TabCollectionStorage +import org.mozilla.fenix.ext.getDefaultCollectionNumber + +/** + * A lambda that is invoked when a confirmation button in a [CollectionsDialog] is clicked. + * + * A [TabCollection] of the selected collected is passed to the delegate when confirmed. If null, + * then a new collection is created. + * + * A list of [TabSessionState] is returned that will be put into the collections storage. + */ +typealias OnPositiveButtonClick = (collection: TabCollection?) -> List + +/** + * A lambda that is invoked when a cancel button in a [CollectionsDialog] is clicked. + */ +typealias OnNegativeButtonClick = () -> Unit + +/** + * A data class for creating a dialog to prompt adding/creating a collection. See also [show]. + * + * @property onPositiveButtonClick Invoked when a user clicks on a confirmation button in the dialog. + * @property onNegativeButtonClick Invoked when a user clicks on a cancel button in the dialog. + */ +data class CollectionsDialog( + val storage: TabCollectionStorage, + val onPositiveButtonClick: OnPositiveButtonClick, + val onNegativeButtonClick: OnNegativeButtonClick +) + +/** + * Create and display a [CollectionsDialog] using [AlertDialog]. + */ +fun CollectionsDialog.show( + context: Context +) { + if (storage.cachedTabCollections.isEmpty()) { + showAddNewDialog(context, storage) + return + } + + val collections = storage.cachedTabCollections.map { it.title } + val layout = LayoutInflater.from(context).inflate(R.layout.add_new_collection_dialog, null) + val list = layout.findViewById(R.id.recycler_view) + + val builder = AlertDialog.Builder(context).setTitle(R.string.tab_tray_select_collection) + .setView(layout) + .setPositiveButton(android.R.string.ok) { dialog, _ -> + val selectedCollection = + (list.adapter as CollectionsListAdapter).getSelectedCollection() + val collection = storage.cachedTabCollections[selectedCollection] + val sessionList = onPositiveButtonClick.invoke(collection) + + MainScope().launch { + storage.addTabsToCollection(collection, sessionList) + } + + dialog.dismiss() + }.setNegativeButton(android.R.string.cancel) { dialog, _ -> + onNegativeButtonClick.invoke() + + dialog.cancel() + } + + val dialog = builder.create() + val collectionNames = + arrayOf(context.getString(R.string.tab_tray_add_new_collection)) + collections + val collectionsListAdapter = CollectionsListAdapter(collectionNames) { + dialog.dismiss() + showAddNewDialog(context, storage) + } + + list.apply { + layoutManager = LinearLayoutManager(context) + adapter = collectionsListAdapter + } + dialog.show() +} + +internal fun CollectionsDialog.showAddNewDialog( + context: Context, + collectionsStorage: TabCollectionStorage +) { + val layout = LayoutInflater.from(context).inflate(R.layout.name_collection_dialog, null) + val collectionNameEditText: EditText = layout.findViewById(R.id.collection_name) + + collectionNameEditText.setText( + context.getString( + R.string.create_collection_default_name, + collectionsStorage.cachedTabCollections.getDefaultCollectionNumber() + ) + ) + + AlertDialog.Builder(context) + .setTitle(R.string.tab_tray_add_new_collection) + .setView(layout).setPositiveButton(android.R.string.ok) { dialog, _ -> + val sessionList = onPositiveButtonClick.invoke(null) + + MainScope().launch { + storage.createCollection( + collectionNameEditText.text.toString(), + sessionList + ) + } + + dialog.dismiss() + } + .setNegativeButton(android.R.string.cancel) { dialog, _ -> + onNegativeButtonClick.invoke() + dialog.cancel() + } + .create() + .show() + + collectionNameEditText.setSelection(0, collectionNameEditText.text.length) + collectionNameEditText.showKeyboard() +} diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt b/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt index 543c868e9..0d031e77d 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/NavigationInteractor.kt @@ -4,19 +4,25 @@ package org.mozilla.fenix.tabstray +import android.content.Context import androidx.navigation.NavController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import mozilla.components.browser.state.selector.getNormalOrPrivateTabs +import mozilla.components.browser.state.selector.normalTabs import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.engine.prompt.ShareData import mozilla.components.concept.tabstray.Tab +import org.mozilla.fenix.collections.CollectionsDialog +import org.mozilla.fenix.collections.show +import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.bookmarks.BookmarksUseCase import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController import org.mozilla.fenix.home.HomeFragment +import org.mozilla.fenix.tabstray.ext.getTabSessionState /** * An interactor that helps with navigating to different parts of the app from the tabs tray. @@ -69,13 +75,15 @@ interface NavigationInteractor { */ @Suppress("LongParameterList") class DefaultNavigationInteractor( - private val tabsTrayStore: TabsTrayStore, + private val context: Context, private val browserStore: BrowserStore, private val navController: NavController, private val metrics: MetricController, private val dismissTabTray: () -> Unit, private val dismissTabTrayAndNavigateHome: (String) -> Unit, - private val bookmarksUseCase: BookmarksUseCase + private val bookmarksUseCase: BookmarksUseCase, + private val tabsTrayStore: TabsTrayStore, + private val collectionStorage: TabCollectionStorage ) : NavigationInteractor { override fun onTabTrayDismissed() { @@ -126,7 +134,25 @@ class DefaultNavigationInteractor( override fun onSaveToCollections(tabs: Collection) { metrics.track(Event.TabsTraySaveToCollectionPressed) - // TODO add this is a separate PR; it's quite a large change. + CollectionsDialog( + storage = collectionStorage, + onPositiveButtonClick = { existingCollection -> + tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) + + // If collection is null, a new one was created. + val event = if (existingCollection == null) { + Event.CollectionSaved(browserStore.state.normalTabs.size, tabs.size) + } else { + Event.CollectionTabsAdded(browserStore.state.normalTabs.size, tabs.size) + } + metrics.track(event) + + browserStore.getTabSessionState(tabs) + }, + onNegativeButtonClick = { + tabsTrayStore.dispatch(TabsTrayAction.ExitSelectMode) + } + ).show(context) } override fun onSaveToBookmarks(tabs: Collection) { diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt index a1ca35761..e73c516c0 100644 --- a/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt +++ b/app/src/main/java/org/mozilla/fenix/tabstray/TabsTrayFragment.kt @@ -93,13 +93,15 @@ class TabsTrayFragment : AppCompatDialogFragment(), TabsTrayInteractor { val navigationInteractor = DefaultNavigationInteractor( + context = requireContext(), tabsTrayStore = tabsTrayStore, browserStore = requireComponents.core.store, navController = findNavController(), metrics = requireComponents.analytics.metrics, dismissTabTray = ::dismissAllowingStateLoss, dismissTabTrayAndNavigateHome = ::dismissTabTrayAndNavigateHome, - bookmarksUseCase = requireComponents.useCases.bookmarksUseCases + bookmarksUseCase = requireComponents.useCases.bookmarksUseCases, + collectionStorage = requireComponents.core.tabCollectionStorage ) tabsTrayController = DefaultTabsTrayController( diff --git a/app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserStore.kt b/app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserStore.kt new file mode 100644 index 000000000..c5577a13e --- /dev/null +++ b/app/src/main/java/org/mozilla/fenix/tabstray/ext/BrowserStore.kt @@ -0,0 +1,19 @@ +/* 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.tabstray.ext + +import mozilla.components.browser.state.selector.findTab +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.tabstray.Tab + +/** + * Find and extract a list [TabSessionState] from the [BrowserStore] using the IDs from [tabs]. + */ +fun BrowserStore.getTabSessionState(tabs: Collection): List { + return tabs.mapNotNull { + state.findTab(it.id) + } +} diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt index 3ddf4f2d4..5ef275512 100644 --- a/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt +++ b/app/src/test/java/org/mozilla/fenix/tabstray/NavigationInteractorTest.kt @@ -4,11 +4,15 @@ package org.mozilla.fenix.tabstray +import android.content.Context import androidx.navigation.NavController import androidx.navigation.NavDirections import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import io.mockk.verify +import io.mockk.mockkStatic +import io.mockk.unmockkStatic import mozilla.components.browser.state.state.BrowserState import mozilla.components.browser.state.state.TabSessionState import mozilla.components.browser.state.state.createTab as createStateTab @@ -17,6 +21,9 @@ import mozilla.components.concept.tabstray.Tab import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.mozilla.fenix.collections.CollectionsDialog +import org.mozilla.fenix.collections.show +import org.mozilla.fenix.components.TabCollectionStorage import org.mozilla.fenix.components.bookmarks.BookmarksUseCase import org.mozilla.fenix.components.metrics.Event import org.mozilla.fenix.components.metrics.MetricController @@ -32,19 +39,23 @@ class NavigationInteractorTest { private val dismissTabTray: () -> Unit = mockk(relaxed = true) private val dismissTabTrayAndNavigateHome: (String) -> Unit = mockk(relaxed = true) private val bookmarksUseCase: BookmarksUseCase = mockk(relaxed = true) + private val context: Context = mockk(relaxed = true) + private val collectionStorage: TabCollectionStorage = mockk(relaxed = true) @Before fun setup() { store = BrowserStore(initialState = BrowserState(tabs = listOf(testTab))) tabsTrayStore = TabsTrayStore() navigationInteractor = DefaultNavigationInteractor( - tabsTrayStore, + context, store, navController, metrics, dismissTabTray, dismissTabTrayAndNavigateHome, - bookmarksUseCase + bookmarksUseCase, + tabsTrayStore, + collectionStorage ) } @@ -151,8 +162,13 @@ class NavigationInteractorTest { @Test fun `onSaveToCollections calls navigation on DefaultNavigationInteractor`() { + mockkStatic("org.mozilla.fenix.collections.CollectionsDialogKt") + + every { any().show(any()) } answers { } navigationInteractor.onSaveToCollections(emptyList()) verify(exactly = 1) { metrics.track(Event.TabsTraySaveToCollectionPressed) } + + unmockkStatic("org.mozilla.fenix.collections.CollectionsDialogKt") } @Test diff --git a/app/src/test/java/org/mozilla/fenix/tabstray/ext/BrowserStoreKtTest.kt b/app/src/test/java/org/mozilla/fenix/tabstray/ext/BrowserStoreKtTest.kt new file mode 100644 index 000000000..4c416870d --- /dev/null +++ b/app/src/test/java/org/mozilla/fenix/tabstray/ext/BrowserStoreKtTest.kt @@ -0,0 +1,60 @@ +/* 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.tabstray.ext + +import io.mockk.mockk +import mozilla.components.browser.state.state.BrowserState +import mozilla.components.browser.state.state.TabSessionState +import mozilla.components.browser.state.store.BrowserStore +import mozilla.components.concept.tabstray.Tab +import org.junit.Assert.assertEquals +import org.junit.Test +import org.mozilla.fenix.tabstray.browser.createTab + +class BrowserStoreKtTest { + + @Test + fun `WHEN session is found THEN return it`() { + val store = BrowserStore( + initialState = BrowserState( + listOf( + TabSessionState(id = "tab1", mockk(), lastAccess = 3), + TabSessionState(id = "tab2", mockk(), lastAccess = 5) + ) + ) + ) + + val tabs = listOf( + createTab("tab1"), + createTab("tab2") + ) + + val result = store.getTabSessionState(tabs) + + assertEquals(3, result[0].lastAccess) + assertEquals(5, result[1].lastAccess) + } + + @Test + fun `WHEN session is not found THEN ignore it`() { + val store = BrowserStore( + initialState = BrowserState( + listOf( + TabSessionState(id = "tab2", mockk(), lastAccess = 5) + ) + ) + ) + + val tabs = listOf( + createTab("tab1"), + createTab("tab2") + ) + + val result = store.getTabSessionState(tabs) + + assertEquals(5, result[0].lastAccess) + assertEquals(1, result.size) + } +}