diff --git a/app/src/androidTest/java/com/duckduckgo/app/statistics/ExperimentationVariantManagerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/statistics/ExperimentationVariantManagerTest.kt index c885adb129d7..14be741efb71 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/statistics/ExperimentationVariantManagerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/statistics/ExperimentationVariantManagerTest.kt @@ -18,6 +18,7 @@ package com.duckduckgo.app.statistics +import com.duckduckgo.app.statistics.VariantManager.Companion.RESERVED_EU_AUCTION_VARIANT import com.duckduckgo.app.statistics.store.StatisticsDataStore import com.nhaarman.mockitokotlin2.* import org.junit.Assert.* @@ -166,12 +167,19 @@ class ExperimentationVariantManagerTest { } @Test - fun whenReferrerVariantReturnedThenNoFeaturesEnabled() { + fun whenUnknownReferrerVariantReturnedThenNoFeaturesEnabled() { mockUpdateScenario("xx") val variant = testee.getVariant(activeVariants) assertTrue(variant.features.isEmpty()) } + @Test + fun whenEuAuctionReferrerVariantReturnedThenSuppressWidgetFeaturesEnabled() { + mockUpdateScenario(RESERVED_EU_AUCTION_VARIANT) + val variant = testee.getVariant(activeVariants) + assertTrue(variant.hasFeature(VariantManager.VariantFeature.SuppressHomeTabWidgetCta)) + } + private fun mockUpdateScenario(key: String) { testee.updateAppReferrerVariant(key) whenever(mockStore.referrerVariant).thenReturn(key) diff --git a/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt b/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt index a7b595be6aef..aade76b3d29c 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/statistics/VariantManagerTest.kt @@ -25,7 +25,7 @@ import org.junit.Test class VariantManagerTest { private val variants = VariantManager.ACTIVE_VARIANTS + - variantWithKey(RESERVED_EU_AUCTION_VARIANT) + + VariantManager.REFERRER_VARIANTS + DEFAULT_VARIANT // SERP Experiment(s) @@ -112,9 +112,4 @@ class VariantManagerTest { fail("Doubles are not equal. Expected $expected but was $actual") } } - - @Suppress("SameParameterValue") - private fun variantWithKey(key: String): Variant { - return DEFAULT_VARIANT.copy(key = key) - } } diff --git a/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt index f820a0af3ccc..a14f18435b71 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt @@ -24,6 +24,9 @@ import com.duckduckgo.app.InstantSchedulersRule import com.duckduckgo.app.autocomplete.api.AutoComplete import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteResult import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion.AutoCompleteSearchSuggestion +import com.duckduckgo.app.onboarding.store.OnboardingStore +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.* import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Command import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Command.LaunchDuckDuckGo import com.nhaarman.mockitokotlin2.argumentCaptor @@ -51,8 +54,10 @@ class SystemSearchViewModelTest { @get:Rule var coroutineRule = CoroutineTestRule() + private val mockOnboardingStore: OnboardingStore = mock() private val mockDeviceAppLookup: DeviceAppLookup = mock() private val mockAutoComplete: AutoComplete = mock() + private val mockPixel: Pixel = mock() private val commandObserver: Observer = mock() private val commandCaptor = argumentCaptor() @@ -65,7 +70,7 @@ class SystemSearchViewModelTest { whenever(mockAutoComplete.autoComplete(BLANK_QUERY)).thenReturn(Observable.just(autocompleteBlankResult)) whenever(mockDeviceAppLookup.query(QUERY)).thenReturn(appQueryResult) whenever(mockDeviceAppLookup.query(BLANK_QUERY)).thenReturn(appBlankResult) - testee = SystemSearchViewModel(mockAutoComplete, mockDeviceAppLookup, coroutineRule.testDispatcherProvider) + testee = SystemSearchViewModel(mockOnboardingStore, mockAutoComplete, mockDeviceAppLookup, mockPixel, coroutineRule.testDispatcherProvider) testee.command.observeForever(commandObserver) } @@ -74,11 +79,70 @@ class SystemSearchViewModelTest { testee.command.removeObserver(commandObserver) } + @Test + fun whenOnboardingShouldNotShowThenViewIsNotVisibleAndUnexpanded() = runBlockingTest { + whenever(mockOnboardingStore.shouldShow).thenReturn(false) + testee.resetViewState() + + val viewState = testee.onboardingViewState.value + assertFalse(viewState!!.visible) + assertFalse(viewState!!.expanded) + } + + @Test + fun whenOnboardingShouldShowThenViewIsVisibleAndUnexpanded() = runBlockingTest { + whenever(mockOnboardingStore.shouldShow).thenReturn(true) + testee.resetViewState() + + val viewState = testee.onboardingViewState.value + assertTrue(viewState!!.visible) + assertFalse(viewState!!.expanded) + } + + @Test + fun whenOnboardingShownThenPixelSent() = runBlockingTest { + whenever(mockOnboardingStore.shouldShow).thenReturn(true) + testee.resetViewState() + verify(mockPixel).fire(INTERSTITIAL_ONBOARDING_SHOWN) + } + + @Test + fun whenOnboardingIsUnexpandedAndUserPressesToggleThenItIsExpandedAndPixelSent() = runBlockingTest { + whenOnboardingShowing() + testee.userTappedOnboardingToggle() + + val viewState = testee.onboardingViewState.value + assertTrue(viewState!!.expanded) + verify(mockPixel).fire(INTERSTITIAL_ONBOARDING_MORE_PRESSED) + } + + @Test + fun whenOnboardingIsExpandedAndUserPressesToggleThenItIsUnexpandedAndPixelSent() = runBlockingTest { + whenOnboardingShowing() + testee.userTappedOnboardingToggle() // first press to expand + testee.userTappedOnboardingToggle() // second press to minimize + + val viewState = testee.onboardingViewState.value + assertFalse(viewState!!.expanded) + verify(mockPixel).fire(INTERSTITIAL_ONBOARDING_LESS_PRESSED) + } + + @Test + fun whenOnboardingIsDismissedThenViewHiddenPixelSentAndOnboardingStoreNotified() = runBlockingTest { + whenOnboardingShowing() + testee.userDismissedOnboarding() + + val viewState = testee.onboardingViewState.value + assertFalse(viewState!!.visible) + verify(mockPixel).fire(INTERSTITIAL_ONBOARDING_DISMISSED) + verify(mockOnboardingStore).onboardingShown() + } + @Test fun whenUserUpdatesQueryThenViewStateUpdated() = ruleRunBlockingTest { testee.userUpdatedQuery(QUERY) - val newViewState = testee.viewState.value + val newViewState = testee.resultsViewState.value assertNotNull(newViewState) assertEquals(QUERY, newViewState?.queryText) assertEquals(appQueryResult, newViewState?.appResults) @@ -90,7 +154,7 @@ class SystemSearchViewModelTest { testee.userUpdatedQuery(QUERY) testee.userUpdatedQuery("$QUERY ") - val newViewState = testee.viewState.value + val newViewState = testee.resultsViewState.value assertNotNull(newViewState) assertEquals("$QUERY ", newViewState?.queryText) assertEquals(appQueryResult, newViewState?.appResults) @@ -102,7 +166,7 @@ class SystemSearchViewModelTest { testee.userUpdatedQuery(QUERY) testee.userClearedQuery() - val newViewState = testee.viewState.value + val newViewState = testee.resultsViewState.value assertNotNull(newViewState) assertTrue(newViewState!!.queryText.isEmpty()) assertTrue(newViewState.appResults.isEmpty()) @@ -114,7 +178,7 @@ class SystemSearchViewModelTest { testee.userUpdatedQuery(QUERY) testee.userUpdatedQuery(BLANK_QUERY) - val newViewState = testee.viewState.value + val newViewState = testee.resultsViewState.value assertNotNull(newViewState) assertTrue(newViewState!!.queryText.isEmpty()) assertTrue(newViewState.appResults.isEmpty()) @@ -122,31 +186,35 @@ class SystemSearchViewModelTest { } @Test - fun whenUserSubmitsQueryThenBrowserLaunched() { + fun whenUserSubmitsQueryThenBrowserLaunchedAndPixelSent() { testee.userSubmittedQuery(QUERY) verify(commandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) assertEquals(Command.LaunchBrowser(QUERY), commandCaptor.lastValue) + verify(mockPixel).fire(INTERSTITIAL_LAUNCH_BROWSER_QUERY) } @Test - fun whenUserSubmitsAutocompleteResultThenBrowserLaunched() { + fun whenUserSubmitsAutocompleteResultThenBrowserLaunchedAndPixelSent() { testee.userSubmittedAutocompleteResult(AUTOCOMPLETE_RESULT) verify(commandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) assertEquals(Command.LaunchBrowser(AUTOCOMPLETE_RESULT), commandCaptor.lastValue) + verify(mockPixel).fire(INTERSTITIAL_LAUNCH_BROWSER_QUERY) } @Test - fun whenUserSelectsAppResultThenAppLaunched() { + fun whenUserSelectsAppResultThenAppLaunchedAndPixelSent() { testee.userSelectedApp(deviceApp) verify(commandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) assertEquals(Command.LaunchDeviceApplication(deviceApp), commandCaptor.lastValue) + verify(mockPixel).fire(INTERSTITIAL_LAUNCH_DEVICE_APP) } @Test - fun whenUserTapsDaxThenAppLaunched() { + fun whenUserTapsDaxThenAppLaunchedAndPixelSent() { testee.userTappedDax() verify(commandObserver, atLeastOnce()).onChanged(commandCaptor.capture()) assertTrue(commandCaptor.lastValue is LaunchDuckDuckGo) + verify(mockPixel).fire(INTERSTITIAL_LAUNCH_DAX) } @Test @@ -157,6 +225,11 @@ class SystemSearchViewModelTest { assertEquals(Command.ShowAppNotFoundMessage(deviceApp.shortName), commandCaptor.lastValue) } + private fun whenOnboardingShowing() { + whenever(mockOnboardingStore.shouldShow).thenReturn(true) + testee.resetViewState() + } + private fun ruleRunBlockingTest(block: suspend TestCoroutineScope.() -> Unit) = coroutineRule.testDispatcher.runBlockingTest(block) diff --git a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt index 807fd01dda13..8a9f4e1f6ce7 100644 --- a/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt +++ b/app/src/main/java/com/duckduckgo/app/global/ViewModelFactory.kt @@ -113,7 +113,7 @@ class ViewModelFactory @Inject constructor( with(modelClass) { when { isAssignableFrom(LaunchViewModel::class.java) -> LaunchViewModel(onboardingStore, appInstallationReferrerStateListener) - isAssignableFrom(SystemSearchViewModel::class.java) -> SystemSearchViewModel(autoCompleteApi, deviceAppLookup) + isAssignableFrom(SystemSearchViewModel::class.java) -> SystemSearchViewModel(onboardingStore, autoCompleteApi, deviceAppLookup, pixel) isAssignableFrom(OnboardingViewModel::class.java) -> onboardingViewModel() isAssignableFrom(BrowserViewModel::class.java) -> browserViewModel() isAssignableFrom(BrowserTabViewModel::class.java) -> browserTabViewModel() diff --git a/app/src/main/java/com/duckduckgo/app/global/device/Device.kt b/app/src/main/java/com/duckduckgo/app/global/device/Device.kt index 08cacb1bba2c..ed6fb7ec3471 100644 --- a/app/src/main/java/com/duckduckgo/app/global/device/Device.kt +++ b/app/src/main/java/com/duckduckgo/app/global/device/Device.kt @@ -43,7 +43,7 @@ interface DeviceInfo { class ContextDeviceInfo @Inject constructor(private val context: Context) : DeviceInfo { - override val appVersion = "${BuildConfig.VERSION_NAME}" + override val appVersion = BuildConfig.VERSION_NAME override val majorAppVersion = appVersion.split(".").first() diff --git a/app/src/main/java/com/duckduckgo/app/global/view/ViewExtension.kt b/app/src/main/java/com/duckduckgo/app/global/view/ViewExtension.kt index afd0188ca162..278f617a4dc7 100644 --- a/app/src/main/java/com/duckduckgo/app/global/view/ViewExtension.kt +++ b/app/src/main/java/com/duckduckgo/app/global/view/ViewExtension.kt @@ -80,4 +80,4 @@ fun View.hideKeyboard(): Boolean { } fun Int.toDp(): Int = (this / Resources.getSystem().displayMetrics.density).toInt() -fun Int.toPx(): Int = (this * Resources.getSystem().displayMetrics.density).toInt() \ No newline at end of file +fun Int.toPx(): Int = (this * Resources.getSystem().displayMetrics.density).toInt() diff --git a/app/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt b/app/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt index 9cd3f620ef82..9e6ce8747c36 100644 --- a/app/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt +++ b/app/src/main/java/com/duckduckgo/app/statistics/VariantManager.kt @@ -84,8 +84,13 @@ interface VariantManager { // All groups in an experiment (control and variants) MUST use the same filters ) + val REFERRER_VARIANTS = listOf( + Variant(RESERVED_EU_AUCTION_VARIANT, features = listOf(SuppressHomeTabWidgetCta), filterBy = { noFilter() }) + ) + fun referrerVariant(key: String): Variant { - return Variant(key, features = emptyList(), filterBy = { noFilter() }) + val knownReferrer = REFERRER_VARIANTS.firstOrNull { it.key == key } + return knownReferrer ?: Variant(key, features = emptyList(), filterBy = { noFilter() }) } private fun noFilter(): Boolean = true diff --git a/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt b/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt index 5ebc022bb3db..d6c2a6c6c44c 100644 --- a/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt +++ b/app/src/main/java/com/duckduckgo/app/statistics/pixels/Pixel.kt @@ -100,6 +100,10 @@ interface Pixel { INTERSTITIAL_LAUNCH_BROWSER_QUERY(pixelName = "m_i_lbq"), INTERSTITIAL_LAUNCH_DEVICE_APP(pixelName = "m_i_sda"), INTERSTITIAL_LAUNCH_DAX(pixelName = "m_i_ld"), + INTERSTITIAL_ONBOARDING_SHOWN(pixelName = "m_io_s"), + INTERSTITIAL_ONBOARDING_DISMISSED(pixelName = "m_io_d"), + INTERSTITIAL_ONBOARDING_LESS_PRESSED(pixelName = "m_io_l"), + INTERSTITIAL_ONBOARDING_MORE_PRESSED(pixelName = "m_io_m"), LONG_PRESS("mlp"), LONG_PRESS_DOWNLOAD_IMAGE("mlp_i"), diff --git a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt index 2c56d8c7025c..23c224a1af39 100644 --- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt @@ -22,6 +22,7 @@ import android.content.Intent import android.os.Bundle import android.text.Editable import android.view.KeyEvent +import android.view.View import android.view.inputmethod.EditorInfo import android.widget.TextView import android.widget.Toast @@ -35,11 +36,13 @@ import com.duckduckgo.app.browser.autocomplete.BrowserAutoCompleteSuggestionsAda import com.duckduckgo.app.browser.omnibar.OmnibarScrolling import com.duckduckgo.app.global.DuckDuckGoActivity import com.duckduckgo.app.global.view.TextChangedWatcher +import com.duckduckgo.app.global.view.hideKeyboard import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelName import com.duckduckgo.app.systemsearch.SystemSearchViewModel.Command.* -import com.duckduckgo.app.systemsearch.SystemSearchViewModel.SystemSearchViewState +import com.duckduckgo.app.systemsearch.SystemSearchViewModel.SystemSearchResultsViewState import kotlinx.android.synthetic.main.activity_system_search.* +import kotlinx.android.synthetic.main.include_system_search_onboarding.* import javax.inject.Inject class SystemSearchActivity : DuckDuckGoActivity() { @@ -65,17 +68,21 @@ class SystemSearchActivity : DuckDuckGoActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_system_search) configureObservers() + configureOnboarding() configureAutoComplete() configureDeviceAppSuggestions() configureDaxButton() configureOmnibar() configureTextInput() - intent?.let { sendLaunchPixels(it) } + + if (savedInstanceState == null) { + intent?.let { sendLaunchPixels(it) } + } } override fun onNewIntent(newIntent: Intent?) { super.onNewIntent(newIntent) - viewModel.resetState() + viewModel.resetViewState() newIntent?.let { sendLaunchPixels(it) } } @@ -88,14 +95,26 @@ class SystemSearchActivity : DuckDuckGoActivity() { } private fun configureObservers() { - viewModel.viewState.observe(this, Observer { - it?.let { renderViewState(it) } + viewModel.onboardingViewState.observe(this, Observer { + it?.let { renderOnboardingViewState(it) } + }) + viewModel.resultsViewState.observe(this, Observer { + it?.let { renderResultsViewState(it) } }) viewModel.command.observe(this, Observer { processCommand(it) }) } + private fun configureOnboarding() { + okButton.setOnClickListener { + viewModel.userDismissedOnboarding() + } + toggleButton.setOnClickListener { + viewModel.userTappedOnboardingToggle() + } + } + private fun configureAutoComplete() { autocompleteSuggestions.layoutManager = LinearLayoutManager(this) autocompleteSuggestionsAdapter = BrowserAutoCompleteSuggestionsAdapter( @@ -124,14 +143,16 @@ class SystemSearchActivity : DuckDuckGoActivity() { } private fun configureOmnibar() { - results.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> - val scrollable = results.maxScrollAmount > MINIMUM_SCROLL_HEIGHT - if (scrollable) { - omnibarScrolling.enableOmnibarScrolling(toolbar) - } else { - showOmnibar() - omnibarScrolling.disableOmnibarScrolling(toolbar) - } + resultsContent.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> updateScroll() } + } + + private fun updateScroll() { + val scrollable = resultsContent.height > (results.height - results.paddingTop - results.paddingBottom) + if (scrollable) { + omnibarScrolling.enableOmnibarScrolling(toolbar) + } else { + showOmnibar() + omnibarScrolling.disableOmnibarScrolling(toolbar) } } @@ -149,7 +170,24 @@ class SystemSearchActivity : DuckDuckGoActivity() { clearTextButton.setOnClickListener { viewModel.userClearedQuery() } } - private fun renderViewState(viewState: SystemSearchViewState) { + private fun renderOnboardingViewState(viewState: SystemSearchViewModel.OnboardingViewState) { + if (viewState.visible) { + onboarding.visibility = View.VISIBLE + results.elevation = 0.0f + checkmarks.visibility = if (viewState.expanded) View.VISIBLE else View.GONE + refreshOnboardingToggleText(viewState.expanded) + } else { + onboarding.visibility = View.GONE + results.elevation = resources.getDimension(R.dimen.systemSearchResultsElevation) + } + } + + private fun refreshOnboardingToggleText(expanded: Boolean) { + val toggleText = if (expanded) R.string.systemSearchOnboardingButtonLess else R.string.systemSearchOnboardingButtonMore + toggleButton.text = getString(toggleText) + } + + private fun renderResultsViewState(viewState: SystemSearchResultsViewState) { if (omnibarTextInput.text.toString() != viewState.queryText) { omnibarTextInput.setText(viewState.queryText) omnibarTextInput.setSelection(viewState.queryText.length) @@ -174,23 +212,23 @@ class SystemSearchActivity : DuckDuckGoActivity() { is ShowAppNotFoundMessage -> { Toast.makeText(this, R.string.systemSearchAppNotFound, LENGTH_SHORT).show() } + is DismissKeyboard -> { + omnibarTextInput.hideKeyboard() + } } } private fun launchDuckDuckGo() { - pixel.fire(PixelName.INTERSTITIAL_LAUNCH_DAX) startActivity(BrowserActivity.intent(this)) finish() } private fun launchBrowser(command: LaunchBrowser) { - pixel.fire(PixelName.INTERSTITIAL_LAUNCH_BROWSER_QUERY) startActivity(BrowserActivity.intent(this, command.query)) finish() } private fun launchDeviceApp(command: LaunchDeviceApplication) { - pixel.fire(PixelName.INTERSTITIAL_LAUNCH_DEVICE_APP) try { startActivity(command.deviceApp.launchIntent) finish() @@ -220,7 +258,6 @@ class SystemSearchActivity : DuckDuckGoActivity() { const val WIDGET_SEARCH_EXTRA = "WIDGET_SEARCH_EXTRA" const val NEW_SEARCH_ACTION = "com.duckduckgo.mobile.android.NEW_SEARCH" - const val MINIMUM_SCROLL_HEIGHT = 86 // enough space for blank "no suggestion" and padding fun intent(context: Context, widgetSearch: Boolean = false): Intent { val intent = Intent(context, SystemSearchActivity::class.java) diff --git a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt index 89edf0920d10..0fb81efbacec 100644 --- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt @@ -24,6 +24,9 @@ import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteResult import com.duckduckgo.app.global.DefaultDispatcherProvider import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.global.SingleLiveEvent +import com.duckduckgo.app.onboarding.store.OnboardingStore +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.app.statistics.pixels.Pixel.PixelName.* import com.jakewharton.rxrelay2.PublishRelay import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable @@ -34,12 +37,19 @@ import timber.log.Timber import java.util.concurrent.TimeUnit class SystemSearchViewModel( + private var onboardingStore: OnboardingStore, private val autoComplete: AutoComplete, private val deviceAppLookup: DeviceAppLookup, + private val pixel: Pixel, private val dispatchers: DispatcherProvider = DefaultDispatcherProvider() ) : ViewModel() { - data class SystemSearchViewState( + data class OnboardingViewState( + val visible: Boolean, + val expanded: Boolean = false + ) + + data class SystemSearchResultsViewState( val queryText: String = "", val autocompleteResults: AutoCompleteResult = AutoCompleteResult("", emptyList()), val appResults: List = emptyList() @@ -50,9 +60,11 @@ class SystemSearchViewModel( data class LaunchBrowser(val query: String) : Command() data class LaunchDeviceApplication(val deviceApp: DeviceApp) : Command() data class ShowAppNotFoundMessage(val appName: String) : Command() + object DismissKeyboard : Command() } - val viewState: MutableLiveData = MutableLiveData() + val onboardingViewState: MutableLiveData = MutableLiveData() + val resultsViewState: MutableLiveData = MutableLiveData() val command: SingleLiveEvent = SingleLiveEvent() private val autoCompletePublishSubject = PublishRelay.create() @@ -63,17 +75,31 @@ class SystemSearchViewModel( private var appResults: List = emptyList() init { - resetState() + resetViewState() configureAutoComplete() } - private fun currentViewState(): SystemSearchViewState = viewState.value!! + private fun currentOnboardingState(): OnboardingViewState = onboardingViewState.value!! + private fun currentResultsState(): SystemSearchResultsViewState = resultsViewState.value!! + + fun resetViewState() { + resetOnboardingState() + resetResultsState() + } + + private fun resetOnboardingState() { + val showOnboarding = onboardingStore.shouldShow + onboardingViewState.value = OnboardingViewState(visible = showOnboarding) + if (showOnboarding) { + pixel.fire(INTERSTITIAL_ONBOARDING_SHOWN) + } + } - fun resetState() { + private fun resetResultsState() { autocompleteResults = AutoCompleteResult("", emptyList()) appsJob?.cancel() appResults = emptyList() - viewState.value = SystemSearchViewState() + resultsViewState.value = SystemSearchResultsViewState() } private fun configureAutoComplete() { @@ -87,11 +113,27 @@ class SystemSearchViewModel( }, { t: Throwable? -> Timber.w(t, "Failed to get search results") }) } + fun userTappedOnboardingToggle() { + onboardingViewState.value = currentOnboardingState().copy(expanded = !currentOnboardingState().expanded) + if (currentOnboardingState().expanded) { + pixel.fire(INTERSTITIAL_ONBOARDING_MORE_PRESSED) + command.value = Command.DismissKeyboard + } else { + pixel.fire(INTERSTITIAL_ONBOARDING_LESS_PRESSED) + } + } + + fun userDismissedOnboarding() { + onboardingViewState.value = currentOnboardingState().copy(visible = false) + onboardingStore.onboardingShown() + pixel.fire(INTERSTITIAL_ONBOARDING_DISMISSED) + } + fun userUpdatedQuery(query: String) { appsJob?.cancel() - if (query == currentViewState().queryText) { + if (query == currentResultsState().queryText) { return } @@ -100,7 +142,7 @@ class SystemSearchViewModel( return } - viewState.value = currentViewState().copy(queryText = query) + resultsViewState.value = currentResultsState().copy(queryText = query) val trimmedQuery = query.trim() autoCompletePublishSubject.accept(trimmedQuery) @@ -111,22 +153,22 @@ class SystemSearchViewModel( private fun updateAppResults(results: List) { appResults = results - refreshViewStateResults() + refreshResultsViewState() } private fun updateAutocompleteResult(results: AutoCompleteResult) { autocompleteResults = results - refreshViewStateResults() + refreshResultsViewState() } - private fun refreshViewStateResults() { + private fun refreshResultsViewState() { val hasMultiResults = autocompleteResults.suggestions.isNotEmpty() && appResults.isNotEmpty() val fullSuggestions = autocompleteResults.suggestions val updatedSuggestions = if (hasMultiResults) fullSuggestions.take(RESULTS_MAX_RESULTS_PER_GROUP) else fullSuggestions val updatedApps = if (hasMultiResults) appResults.take(RESULTS_MAX_RESULTS_PER_GROUP) else appResults - viewState.postValue( - currentViewState().copy( + resultsViewState.postValue( + currentResultsState().copy( autocompleteResults = AutoCompleteResult(autocompleteResults.query, updatedSuggestions), appResults = updatedApps ) @@ -134,24 +176,28 @@ class SystemSearchViewModel( } fun userTappedDax() { + pixel.fire(INTERSTITIAL_LAUNCH_DAX) command.value = Command.LaunchDuckDuckGo } fun userClearedQuery() { autoCompletePublishSubject.accept("") - resetState() + resetResultsState() } fun userSubmittedQuery(query: String) { command.value = Command.LaunchBrowser(query) + pixel.fire(INTERSTITIAL_LAUNCH_BROWSER_QUERY) } fun userSubmittedAutocompleteResult(query: String) { command.value = Command.LaunchBrowser(query) + pixel.fire(INTERSTITIAL_LAUNCH_BROWSER_QUERY) } fun userSelectedApp(app: DeviceApp) { command.value = Command.LaunchDeviceApplication(app) + pixel.fire(INTERSTITIAL_LAUNCH_DEVICE_APP) } fun appNotFound(app: DeviceApp) { diff --git a/app/src/main/res/drawable/ic_success.xml b/app/src/main/res/drawable/ic_success.xml new file mode 100644 index 000000000000..d8260c2baa5d --- /dev/null +++ b/app/src/main/res/drawable/ic_success.xml @@ -0,0 +1,29 @@ + + + + + + diff --git a/app/src/main/res/drawable/system_search_onboarding_high_five.xml b/app/src/main/res/drawable/system_search_onboarding_high_five.xml new file mode 100644 index 000000000000..c7143252cfb7 --- /dev/null +++ b/app/src/main/res/drawable/system_search_onboarding_high_five.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_system_search.xml b/app/src/main/res/layout/activity_system_search.xml index e198aa6f7f1a..43c06a1e8cdb 100644 --- a/app/src/main/res/layout/activity_system_search.xml +++ b/app/src/main/res/layout/activity_system_search.xml @@ -67,7 +67,7 @@ android:layout_marginEnd="16dp" android:background="@android:color/transparent" android:fontFamily="sans-serif-medium" - android:hint="@string/omnibarInputHint" + android:hint="@string/systemSearchOmnibarInputHint" android:imeOptions="flagNoExtractUi|actionGo|flagNoPersonalizedLearning" android:inputType="textUri|textNoSuggestions" android:maxLines="1" @@ -107,19 +107,20 @@ + + diff --git a/app/src/main/res/layout/content_about_duck_duck_go.xml b/app/src/main/res/layout/content_about_duck_duck_go.xml index de0b60758479..55647d8571d4 100644 --- a/app/src/main/res/layout/content_about_duck_duck_go.xml +++ b/app/src/main/res/layout/content_about_duck_duck_go.xml @@ -52,7 +52,7 @@ android:layout_marginBottom="20dp" android:fontFamily="sans-serif" android:lineSpacingExtra="6sp" - android:text="@string/aboutHeading" + android:text="@string/duckDuckGoPrivacySimplified" android:textAlignment="center" android:textColor="?attr/normalTextColor" android:textSize="20sp" diff --git a/app/src/main/res/layout/include_system_search_onboarding.xml b/app/src/main/res/layout/include_system_search_onboarding.xml new file mode 100644 index 000000000000..2c605ecee8c8 --- /dev/null +++ b/app/src/main/res/layout/include_system_search_onboarding.xml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + +