diff --git a/app/src/androidTest/java/com/duckduckgo/app/bookmarks/model/FavoritesDataRepositoryTest.kt b/app/src/androidTest/java/com/duckduckgo/app/bookmarks/model/FavoritesDataRepositoryTest.kt index 2a693aa6241f..10d4d1543a65 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/bookmarks/model/FavoritesDataRepositoryTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/bookmarks/model/FavoritesDataRepositoryTest.kt @@ -139,6 +139,14 @@ class FavoritesDataRepositoryTest { verify(mockFaviconManager).deletePersistedFavicon(favorite.url) } + @Test + fun whenUserHasFavoritesThenReturnTrue() = coroutineRule.runBlocking { + val favorite = Favorite(1, "Favorite", "http://favexample.com", 1) + givenFavorite(favorite) + + assertTrue(repository.userHasFavorites()) + } + private fun givenFavorite(vararg favorite: Favorite) { favorite.forEach { favoritesDao.insert(FavoriteEntity(it.id, it.title, it.url, it.position)) diff --git a/app/src/androidTest/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModelTest.kt index 35880ef6549f..d92643c09e23 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModelTest.kt @@ -27,7 +27,9 @@ import com.duckduckgo.app.bookmarks.model.FavoritesRepository import com.duckduckgo.app.bookmarks.model.SavedSite import com.duckduckgo.app.bookmarks.service.SavedSitesManager import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.pixels.AppPixelName import com.duckduckgo.app.runBlocking +import com.duckduckgo.app.statistics.pixels.Pixel import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever @@ -65,13 +67,14 @@ class BookmarksViewModelTest { private val favoritesRepository: FavoritesRepository = mock() private val faviconManager: FaviconManager = mock() private val savedSitesManager: SavedSitesManager = mock() + private val pixel: Pixel = mock() private val bookmark = SavedSite.Bookmark(id = 0, title = "title", url = "www.example.com") private val favorite = SavedSite.Favorite(id = 0, title = "title", url = "www.example.com", position = 0) private val bookmarkEntity = BookmarkEntity(id = bookmark.id, title = bookmark.title, url = bookmark.url) private val testee: BookmarksViewModel by lazy { - val model = BookmarksViewModel(favoritesRepository, bookmarksDao, faviconManager, savedSitesManager, coroutineRule.testDispatcherProvider) + val model = BookmarksViewModel(favoritesRepository, bookmarksDao, faviconManager, savedSitesManager, pixel, coroutineRule.testDispatcherProvider) model.viewState.observeForever(viewStateObserver) model.command.observeForever(commandObserver) model @@ -142,6 +145,13 @@ class BookmarksViewModelTest { assertTrue(captor.value is BookmarksViewModel.Command.OpenSavedSite) } + @Test + fun whenFavoriteSelectedThenPixelSent() { + testee.onSelected(favorite) + + verify(pixel).fire(AppPixelName.FAVORITE_BOOKMARKS_ITEM_PRESSED) + } + @Test fun whenDeleteRequestedThenConfirmCommand() { testee.onDeleteSavedSiteRequested(bookmark) diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt index 6a8b9f30c5ad..3bf59366a7db 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserTabViewModelTest.kt @@ -44,7 +44,7 @@ import com.duckduckgo.app.bookmarks.model.SavedSite import com.duckduckgo.app.bookmarks.model.SavedSite.Favorite import com.duckduckgo.app.browser.BrowserTabViewModel.Command import com.duckduckgo.app.browser.BrowserTabViewModel.Command.Navigate -import com.duckduckgo.app.browser.BrowserTabViewModel.FireButton +import com.duckduckgo.app.browser.BrowserTabViewModel.HighlightableButton import com.duckduckgo.app.browser.LongPressHandler.RequiredAction.DownloadFile import com.duckduckgo.app.browser.LongPressHandler.RequiredAction.OpenInNewTab import com.duckduckgo.app.browser.addtohome.AddToHomeCapabilityDetector @@ -382,7 +382,7 @@ class BrowserTabViewModelTest { temporaryTrackingWhitelistDao = mockTemporaryTrackingWhitelistDao ) - testee.loadData("abc", null, false) + testee.loadData("abc", null, false, false) testee.command.observeForever(mockCommandObserver) } @@ -504,21 +504,21 @@ class BrowserTabViewModelTest { fun whenBrowsingAndUrlPresentThenAddBookmarkFavoriteButtonsEnabled() { loadUrl("www.example.com", isBrowserShowing = true) assertTrue(browserViewState().canAddBookmarks) - assertTrue(browserViewState().canAddFavorite) + assertTrue(browserViewState().addFavorite.isEnabled()) } @Test fun whenBrowsingAndNoUrlThenAddBookmarkFavoriteButtonsDisabled() { loadUrl(null, isBrowserShowing = true) assertFalse(browserViewState().canAddBookmarks) - assertFalse(browserViewState().canAddFavorite) + assertFalse(browserViewState().addFavorite.isEnabled()) } @Test fun whenNotBrowsingAndUrlPresentThenAddBookmarkFavoriteButtonsDisabled() { loadUrl("www.example.com", isBrowserShowing = false) assertFalse(browserViewState().canAddBookmarks) - assertFalse(browserViewState().canAddFavorite) + assertFalse(browserViewState().addFavorite.isEnabled()) } @Test @@ -973,31 +973,31 @@ class BrowserTabViewModelTest { @Test fun whenInitialisedThenFireButtonIsShown() { - assertTrue(browserViewState().fireButton is FireButton.Visible) + assertTrue(browserViewState().fireButton is HighlightableButton.Visible) } @Test fun whenOmnibarInputDoesNotHaveFocusAndHasQueryThenFireButtonIsShown() { testee.onOmnibarInputStateChanged("query", false, hasQueryChanged = false) - assertTrue(browserViewState().fireButton is FireButton.Visible) + assertTrue(browserViewState().fireButton is HighlightableButton.Visible) } @Test fun whenOmnibarInputDoesNotHaveFocusOrQueryThenFireButtonIsShown() { testee.onOmnibarInputStateChanged("", false, hasQueryChanged = false) - assertTrue(browserViewState().fireButton is FireButton.Visible) + assertTrue(browserViewState().fireButton is HighlightableButton.Visible) } @Test fun whenOmnibarInputHasFocusAndNoQueryThenFireButtonIsShown() { testee.onOmnibarInputStateChanged("", true, hasQueryChanged = false) - assertTrue(browserViewState().fireButton is FireButton.Visible) + assertTrue(browserViewState().fireButton is HighlightableButton.Visible) } @Test fun whenOmnibarInputHasFocusAndQueryThenFireButtonIsHidden() { testee.onOmnibarInputStateChanged("query", true, hasQueryChanged = false) - assertTrue(browserViewState().fireButton is FireButton.Gone) + assertTrue(browserViewState().fireButton is HighlightableButton.Gone) } @Test @@ -1031,31 +1031,31 @@ class BrowserTabViewModelTest { @Test fun whenInitialisedThenMenuButtonIsShown() { - assertTrue(browserViewState().showMenuButton) + assertTrue(browserViewState().showMenuButton.isEnabled()) } @Test fun whenOmnibarInputDoesNotHaveFocusOrQueryThenMenuButtonIsShown() { testee.onOmnibarInputStateChanged("", false, hasQueryChanged = false) - assertTrue(browserViewState().showMenuButton) + assertTrue(browserViewState().showMenuButton.isEnabled()) } @Test fun whenOmnibarInputDoesNotHaveFocusAndHasQueryThenMenuButtonIsShown() { testee.onOmnibarInputStateChanged("query", false, hasQueryChanged = false) - assertTrue(browserViewState().showMenuButton) + assertTrue(browserViewState().showMenuButton.isEnabled()) } @Test fun whenOmnibarInputHasFocusAndNoQueryThenMenuButtonIsShown() { testee.onOmnibarInputStateChanged("", true, hasQueryChanged = false) - assertTrue(browserViewState().showMenuButton) + assertTrue(browserViewState().showMenuButton.isEnabled()) } @Test fun whenOmnibarInputHasFocusAndQueryThenMenuButtonIsHidden() { testee.onOmnibarInputStateChanged("query", true, hasQueryChanged = false) - assertFalse(browserViewState().showMenuButton) + assertFalse(browserViewState().showMenuButton.isEnabled()) } @Test @@ -1569,25 +1569,64 @@ class BrowserTabViewModelTest { @Test fun whenUrlNullThenSetBrowserNotShowing() = coroutineRule.runBlocking { - testee.loadData("id", null, false) + testee.loadData("id", null, false, false) testee.determineShowBrowser() assertEquals(false, testee.browserViewState.value?.browserShowing) } @Test fun whenUrlBlankThenSetBrowserNotShowing() = coroutineRule.runBlocking { - testee.loadData("id", " ", false) + testee.loadData("id", " ", false, false) testee.determineShowBrowser() assertEquals(false, testee.browserViewState.value?.browserShowing) } @Test fun whenUrlPresentThenSetBrowserShowing() = coroutineRule.runBlocking { - testee.loadData("id", "https://example.com", false) + testee.loadData("id", "https://example.com", false, false) testee.determineShowBrowser() assertEquals(true, testee.browserViewState.value?.browserShowing) } + @Test + fun whenFavoritesOnboardingAndSiteLoadedThenHighglightMenuButton() = coroutineRule.runBlocking { + testee.loadData("id", "https://example.com", false, true) + testee.determineShowBrowser() + assertEquals(true, testee.browserViewState.value?.showMenuButton?.isHighlighted()) + } + + @Test + fun whenFavoritesOnboardingAndUserOpensOptionsMenuThenHighglightAddFavoriteOption() = coroutineRule.runBlocking { + testee.loadData("id", "https://example.com", false, true) + testee.determineShowBrowser() + + testee.onBrowserMenuClicked() + + assertEquals(true, testee.browserViewState.value?.addFavorite?.isHighlighted()) + } + + @Test + fun whenFavoritesOnboardingAndUserClosesOptionsMenuThenMenuButtonNotHighlighted() = coroutineRule.runBlocking { + testee.loadData("id", "https://example.com", false, true) + testee.determineShowBrowser() + + testee.onBrowserMenuClosed() + + assertEquals(false, testee.browserViewState.value?.addFavorite?.isHighlighted()) + } + + @Test + fun whenFavoritesOnboardingAndUserClosesOptionsMenuThenLoadingNewSiteDoesNotHighlightMenuOption() = coroutineRule.runBlocking { + testee.loadData("id", "https://example.com", false, true) + testee.determineShowBrowser() + testee.onBrowserMenuClicked() + testee.onBrowserMenuClosed() + + testee.determineShowBrowser() + + assertEquals(false, testee.browserViewState.value?.addFavorite?.isHighlighted()) + } + @Test fun whenRecoveringFromProcessGoneThenShowErrorWithAction() { testee.recoverFromRenderProcessGone() @@ -2231,7 +2270,7 @@ class BrowserTabViewModelTest { setupNavigation(skipHome = false, isBrowsing = true, canGoBack = false) assertTrue(testee.onUserPressedBack()) assertFalse(browserViewState().canAddBookmarks) - assertFalse(browserViewState().canAddFavorite) + assertFalse(browserViewState().addFavorite.isEnabled()) } @Test @@ -2289,7 +2328,7 @@ class BrowserTabViewModelTest { testee.onUserPressedBack() testee.onUserPressedForward() assertTrue(browserViewState().canAddBookmarks) - assertTrue(browserViewState().canAddFavorite) + assertTrue(browserViewState().addFavorite.isEnabled()) } @Test @@ -3345,7 +3384,7 @@ class BrowserTabViewModelTest { private fun givenOneActiveTabSelected() { selectedTabLiveData.value = TabEntity("TAB_ID", "https://example.com", "", skipHome = false, viewed = true, position = 0) - testee.loadData("TAB_ID", "https://example.com", false) + testee.loadData("TAB_ID", "https://example.com", false, false) } private fun givenFireproofWebsiteDomain(vararg fireproofWebsitesDomain: String) { @@ -3363,7 +3402,7 @@ class BrowserTabViewModelTest { val siteLiveData = MutableLiveData() siteLiveData.value = site whenever(mockTabRepository.retrieveSiteData("TAB_ID")).thenReturn(siteLiveData) - testee.loadData("TAB_ID", domain, false) + testee.loadData("TAB_ID", domain, false, false) } private fun setBrowserShowing(isBrowsing: Boolean) { diff --git a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt index 9b811ebec6b3..7d51bd1fe937 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/browser/BrowserViewModelTest.kt @@ -208,6 +208,22 @@ class BrowserViewModelTest { verify(mockPixel).fire(AppPixelName.SHORTCUT_OPENED) } + @Test + fun whenOpenFavoriteThenSelectByUrlOrNewTab() = coroutinesTestRule.runBlocking { + val url = "example.com" + whenever(mockOmnibarEntryConverter.convertQueryToUrl(url)).thenReturn(url) + testee.onOpenFavoriteFromWidget(url) + verify(mockTabRepository).selectByUrlOrNewTab(url) + } + + @Test + fun whenOpenFavoriteFromWidgetThenFirePixel() = coroutinesTestRule.runBlocking { + val url = "example.com" + whenever(mockOmnibarEntryConverter.convertQueryToUrl(url)).thenReturn(url) + testee.onOpenFavoriteFromWidget(url) + verify(mockPixel).fire(AppPixelName.APP_FAVORITES_ITEM_WIDGET_LAUNCH) + } + companion object { const val TAB_ID = "TAB_ID" } diff --git a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt index 069549b38fc3..d14fde9a3ff9 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/cta/ui/CtaViewModelTest.kt @@ -490,6 +490,12 @@ class CtaViewModelTest { assertNull(value) } + @Test + fun whenRefreshCtaInHomeTabDuringFavoriteOnboardingThenReturnNull() = runBlockingTest { + val value = testee.refreshCta(coroutineRule.testDispatcher, isBrowserShowing = false, favoritesOnboarding = true) + assertTrue(value is BubbleCta.DaxFavoritesOnboardingCta) + } + @Test fun whenUserHidesAllTipsThenFireButtonAnimationShouldNotShow() = coroutineRule.runBlocking { givenOnboardingActive() 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 43cb136e46bf..07c4608fc945 100644 --- a/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt +++ b/app/src/androidTest/java/com/duckduckgo/app/systemsearch/SystemSearchViewModelTest.kt @@ -284,6 +284,15 @@ class SystemSearchViewModelTest { assertEquals(Command.LaunchBrowser(quickAccessItem.favorite.url), commandCaptor.lastValue) } + @Test + fun whenQuickAccessItemClickedThenPixelSent() { + val quickAccessItem = QuickAccessFavorite(Favorite(1, "title", "http://example.com", 0)) + + testee.onQuickAccesItemClicked(quickAccessItem) + + verify(mockPixel).fire(FAVORITE_SYSTEM_SEARCH_ITEM_PRESSED) + } + @Test fun whenQuickAccessItemEditRequestedThenLaunchEditDialog() { val quickAccessItem = QuickAccessFavorite(Favorite(1, "title", "http://example.com", 0)) diff --git a/app/src/androidTest/java/com/duckduckgo/widget/SearchAndFavoritesGridCalculatorKtTest.kt b/app/src/androidTest/java/com/duckduckgo/widget/SearchAndFavoritesGridCalculatorKtTest.kt new file mode 100644 index 000000000000..0e92060406c2 --- /dev/null +++ b/app/src/androidTest/java/com/duckduckgo/widget/SearchAndFavoritesGridCalculatorKtTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.widget + +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Test + +import org.junit.Assert.* +import org.junit.experimental.runners.Enclosed +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Enclosed::class) +class SearchAndFavoritesGridCalculatorKtTest { + + @RunWith(Parameterized::class) + class SearchAndFavoritesGridColumnCalculatorKtTest(private val testCase: TestCase) { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + private val testee = SearchAndFavoritesGridCalculator() + + @Test + fun calculateColumnsBasedOnAvailableWidth() { + val columns = testee.calculateColumns(context, testCase.width) + + assertEquals(testCase.expectedColumns, columns) + } + + companion object { + data class TestCase(val expectedColumns: Int, val width: Int) + + @JvmStatic + @Parameterized.Parameters(name = "Test case: {index} - {0}") + fun testData(): Array { + return arrayOf( + TestCase(2, 100), + TestCase(2, 144), + TestCase(3, 212), + TestCase(3, 279), + TestCase(4, 280), + TestCase(5, 348), + TestCase(6, 416), + TestCase(7, 484), + TestCase(8, 552) + ) + } + } + } + + @RunWith(Parameterized::class) + class SearchAndFavoritesGridRowsCalculatorKtTest(private val testCase: TestCase) { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + + private val testee = SearchAndFavoritesGridCalculator() + + @Test + fun calculateRowsBasedOnAvailableHeight() { + val rows = testee.calculateRows(context, testCase.width) + + assertEquals(testCase.expectedRows, rows) + } + + companion object { + data class TestCase(val expectedRows: Int, val width: Int) + + @JvmStatic + @Parameterized.Parameters(name = "Test case: {index} - {0}") + fun testData(): Array { + return arrayOf( + TestCase(1, 100), + TestCase(1, 172), + TestCase(2, 270), + TestCase(3, 368), + TestCase(3, 465), + TestCase(4, 466), + TestCase(4, 564), + TestCase(4, 662), + TestCase(4, 760), + TestCase(4, 858) + ) + } + } + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8664e716dea3..0738cb70c68b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -331,13 +331,31 @@ android:name="com.duckduckgo.app.email.ui.EmailWebViewActivity" android:label="@string/emailProtectionWebViewActivityTitle" android:parentActivityName="com.duckduckgo.app.email.ui.EmailProtectionActivity" /> + + + + + + + + + + + - + @@ -346,7 +364,7 @@ android:resource="@xml/search_widget_info" /> - + @@ -355,6 +373,15 @@ android:resource="@xml/search_widget_info_light" /> + + + + + + + diff --git a/app/src/main/java/com/duckduckgo/app/WidgetThemeConfiguration.kt b/app/src/main/java/com/duckduckgo/app/WidgetThemeConfiguration.kt new file mode 100644 index 000000000000..ad90232e869f --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/WidgetThemeConfiguration.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.content.res.Configuration +import android.os.Build +import android.os.Bundle +import android.view.View +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.databinding.ActivityWidgetConfigurationBinding +import com.duckduckgo.app.global.DuckDuckGoActivity +import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.statistics.pixels.Pixel +import com.duckduckgo.widget.WidgetPreferences +import com.duckduckgo.widget.WidgetTheme +import javax.inject.Inject + +class WidgetThemeConfiguration : DuckDuckGoActivity() { + + @Inject + lateinit var widgetPrefs: WidgetPreferences + + @Inject + lateinit var pixel: Pixel + + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val binding = ActivityWidgetConfigurationBinding.inflate(layoutInflater) + setContentView(binding.root) + val extras = intent.extras + extras?.let { + appWidgetId = it.getInt(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + } + + val resultValue = Intent() + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + setResult(RESULT_CANCELED, resultValue) + + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + binding.widgetConfigThemeSystem.visibility = View.VISIBLE + binding.widgetConfigThemeSystem.isChecked = true + } else { + binding.widgetConfigThemeSystem.visibility = View.GONE + val currentNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + if (currentNightMode == Configuration.UI_MODE_NIGHT_YES) { + binding.widgetConfigThemeDark.isChecked = true + } else { + binding.widgetConfigThemeLight.isChecked = true + } + } + + binding.widgetConfigThemeRadioGroup.setOnCheckedChangeListener { _, radioId -> + when (radioId) { + R.id.widgetConfigThemeSystem -> { + val currentNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + if (currentNightMode == Configuration.UI_MODE_NIGHT_YES) { + binding.widgetConfigPreview.setImageResource(R.drawable.search_favorites_widget_dark_preview) + } else { + binding.widgetConfigPreview.setImageResource(R.drawable.search_favorites_widget_preview) + } + } + R.id.widgetConfigThemeLight -> { + binding.widgetConfigPreview.setImageResource(R.drawable.search_favorites_widget_preview) + } + R.id.widgetConfigThemeDark -> { + binding.widgetConfigPreview.setImageResource(R.drawable.search_favorites_widget_dark_preview) + } + } + } + + binding.widgetConfigAddWidgetButton.setOnClickListener { + val selectedTheme = when (binding.widgetConfigThemeRadioGroup.checkedRadioButtonId) { + R.id.widgetConfigThemeLight -> { + WidgetTheme.LIGHT + } + R.id.widgetConfigThemeDark -> { + WidgetTheme.DARK + } + R.id.widgetConfigThemeSystem -> { + WidgetTheme.SYSTEM_DEFAULT + } + else -> throw IllegalArgumentException("Unknown Radio button Id") + } + storeAndSubmitConfiguration(appWidgetId, selectedTheme) + } + + pixel.fire(AppPixelName.FAVORITE_WIDGET_CONFIGURATION_SHOWN) + } + + private fun storeAndSubmitConfiguration(widgetId: Int, selectedTheme: WidgetTheme) { + widgetPrefs.saveWidgetSelectedTheme(widgetId, selectedTheme.toString()) + pixelSelectedTheme(selectedTheme) + val widgetUpdateIntent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE) + val widgetsToUpdate = IntArray(1).also { it[0] = widgetId } + widgetUpdateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, widgetsToUpdate) + sendBroadcast(widgetUpdateIntent) + + val resultValue = Intent() + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + setResult(RESULT_OK, resultValue) + finish() + } + + private fun pixelSelectedTheme(selectedTheme: WidgetTheme) { + when (selectedTheme) { + WidgetTheme.LIGHT -> pixel.fire(AppPixelName.FAVORITES_WIDGETS_LIGHT) + WidgetTheme.DARK -> pixel.fire(AppPixelName.FAVORITES_WIDGETS_DARK) + WidgetTheme.SYSTEM_DEFAULT -> pixel.fire(AppPixelName.FAVORITES_WIDGETS_SYSTEM) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/db/FavoritesDao.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/db/FavoritesDao.kt index f57fe4b2bfa0..db44b73332a7 100644 --- a/app/src/main/java/com/duckduckgo/app/bookmarks/db/FavoritesDao.kt +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/db/FavoritesDao.kt @@ -33,6 +33,9 @@ interface FavoritesDao { @Query("select * from favorites order by position") fun favoritesSync(): List + @Query("select count(1) > 0 from favorites") + fun userHasFavorites(): Boolean + @Query("select count(*) from favorites WHERE url LIKE :domain") fun favoritesCountByUrl(domain: String): Int diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/model/FavoritesRepository.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/model/FavoritesRepository.kt index e64f205bfeec..183af45fdb3f 100644 --- a/app/src/main/java/com/duckduckgo/app/bookmarks/model/FavoritesRepository.kt +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/model/FavoritesRepository.kt @@ -35,6 +35,7 @@ interface FavoritesRepository { fun update(favorite: SavedSite.Favorite) fun updateWithPosition(favorites: List) fun favorites(): Flow> + fun userHasFavorites(): Boolean suspend fun delete(favorite: SavedSite.Favorite) } @@ -97,6 +98,10 @@ class FavoritesDataRepository( return favoritesDao.favorites().distinctUntilChanged().map { favorites -> favorites.mapToSavedSites() } } + override fun userHasFavorites(): Boolean { + return favoritesDao.userHasFavorites() + } + override suspend fun delete(favorite: SavedSite.Favorite) { faviconManager.get().deletePersistedFavicon(favorite.url) favoritesDao.delete(FavoriteEntity(favorite.id, favorite.title, favorite.url, favorite.position)) diff --git a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModel.kt b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModel.kt index 3dd4ca540c4e..325cb9e87ca6 100644 --- a/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/bookmarks/ui/BookmarksViewModel.kt @@ -33,6 +33,8 @@ import com.duckduckgo.app.browser.favicon.FaviconManager import com.duckduckgo.app.global.DispatcherProvider import com.duckduckgo.app.global.SingleLiveEvent import com.duckduckgo.app.global.plugins.view_model.ViewModelFactoryPlugin +import com.duckduckgo.app.pixels.AppPixelName +import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.di.scopes.AppObjectGraph import com.squareup.anvil.annotations.ContributesMultibinding import kotlinx.coroutines.NonCancellable @@ -47,6 +49,7 @@ class BookmarksViewModel( val dao: BookmarksDao, private val faviconManager: FaviconManager, private val savedSitesManager: SavedSitesManager, + private val pixel: Pixel, private val dispatcherProvider: DispatcherProvider ) : EditSavedSiteListener, ViewModel() { @@ -107,6 +110,9 @@ class BookmarksViewModel( } fun onSelected(savedSite: SavedSite) { + if (savedSite is Favorite) { + pixel.fire(AppPixelName.FAVORITE_BOOKMARKS_ITEM_PRESSED) + } command.value = OpenSavedSite(savedSite) } @@ -198,6 +204,7 @@ class BookmarksViewModelFactory @Inject constructor( private val dao: Provider, private val faviconManager: Provider, private val savedSitesManager: Provider, + private val pixel: Provider, private val dispatcherProvider: Provider ) : ViewModelFactoryPlugin { override fun create(modelClass: Class): T? { @@ -209,6 +216,7 @@ class BookmarksViewModelFactory @Inject constructor( dao.get(), faviconManager.get(), savedSitesManager.get(), + pixel.get(), dispatcherProvider.get() ) as T ) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index f348c08e825c..bbf5cbd80690 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -169,6 +169,20 @@ class BrowserActivity : DuckDuckGoActivity(), CoroutineScope by MainScope() { private fun openNewTab(tabId: String, url: String? = null, skipHome: Boolean): BrowserTabFragment { Timber.i("Opening new tab, url: $url, tabId: $tabId") val fragment = BrowserTabFragment.newInstance(tabId, url, skipHome) + addOrReplaceNewTab(fragment, tabId) + currentTab = fragment + return fragment + } + + private fun openFavoritesOnboardingNewTab(tabId: String): BrowserTabFragment { + pixel.fire(AppPixelName.APP_EMPTY_VIEW_WIDGET_LAUNCH) + val fragment = BrowserTabFragment.newInstanceFavoritesOnboarding(tabId) + addOrReplaceNewTab(fragment, tabId) + currentTab = fragment + return fragment + } + + private fun addOrReplaceNewTab(fragment: BrowserTabFragment, tabId: String) { val transaction = supportFragmentManager.beginTransaction() val tab = currentTab if (tab == null) { @@ -178,8 +192,6 @@ class BrowserActivity : DuckDuckGoActivity(), CoroutineScope by MainScope() { transaction.add(R.id.fragmentContainer, fragment, tabId) } transaction.commit() - currentTab = fragment - return fragment } private fun selectTab(tab: TabEntity?) { @@ -240,6 +252,14 @@ class BrowserActivity : DuckDuckGoActivity(), CoroutineScope by MainScope() { Toast.makeText(applicationContext, R.string.fireDataCleared, Toast.LENGTH_LONG).show() } + if (intent.getBooleanExtra(FAVORITES_ONBOARDING_EXTRA, false)) { + launch { + val tabId = viewModel.onNewTabRequested() + openFavoritesOnboardingNewTab(tabId) + } + return + } + if (launchNewSearch(intent)) { Timber.w("new tab requested") launch { viewModel.onNewTabRequested() } @@ -251,6 +271,10 @@ class BrowserActivity : DuckDuckGoActivity(), CoroutineScope by MainScope() { if (intent.getBooleanExtra(ShortcutBuilder.SHORTCUT_EXTRA_ARG, false)) { Timber.d("Shortcut opened with url $sharedText") launch { viewModel.onOpenShortcut(sharedText) } + } else if (intent.getBooleanExtra(LAUNCH_FROM_FAVORITES_WIDGET, false)) { + Timber.d("Favorite clicked from widget $sharedText") + launch { viewModel.onOpenFavoriteFromWidget(query = sharedText) } + return } else { Timber.w("opening in new tab requested for $sharedText") launch { viewModel.onOpenInNewTabRequested(query = sharedText, skipHome = true) } @@ -422,10 +446,12 @@ class BrowserActivity : DuckDuckGoActivity(), CoroutineScope by MainScope() { return intent } + const val FAVORITES_ONBOARDING_EXTRA = "FAVORITES_ONBOARDING_EXTRA" const val NEW_SEARCH_EXTRA = "NEW_SEARCH_EXTRA" const val PERFORM_FIRE_ON_ENTRY_EXTRA = "PERFORM_FIRE_ON_ENTRY_EXTRA" const val NOTIFY_DATA_CLEARED_EXTRA = "NOTIFY_DATA_CLEARED_EXTRA" const val LAUNCH_FROM_DEFAULT_BROWSER_DIALOG = "LAUNCH_FROM_DEFAULT_BROWSER_DIALOG" + const val LAUNCH_FROM_FAVORITES_WIDGET = "LAUNCH_FROM_FAVORITES_WIDGET" private const val APP_ENJOYMENT_DIALOG_TAG = "AppEnjoyment" diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserPopupMenu.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserPopupMenu.kt index 499174d6c93a..32dbe76a6591 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserPopupMenu.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserPopupMenu.kt @@ -48,12 +48,13 @@ class BrowserPopupMenu(layoutInflater: LayoutInflater, variant: Variant, view: V } } - fun show(rootView: View, anchorView: View) { + fun show(rootView: View, anchorView: View, onDismiss: () -> Unit) { val anchorLocation = IntArray(2) anchorView.getLocationOnScreen(anchorLocation) val x = margin val y = anchorLocation[1] + margin showAtLocation(rootView, Gravity.TOP or Gravity.END, x, y) + setOnDismissListener(onDismiss) } companion object { diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserStateModifier.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserStateModifier.kt index 9c3b8e396dfb..a556757a9047 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserStateModifier.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserStateModifier.kt @@ -17,6 +17,7 @@ package com.duckduckgo.app.browser import com.duckduckgo.app.browser.BrowserTabViewModel.BrowserViewState +import com.duckduckgo.app.browser.BrowserTabViewModel.HighlightableButton import io.reactivex.annotations.CheckReturnValue class BrowserStateModifier { @@ -30,7 +31,7 @@ class BrowserStateModifier { canReportSite = true, canSharePage = true, canAddBookmarks = true, - canAddFavorite = true, + addFavorite = HighlightableButton.Visible(), addToHomeEnabled = true ) } @@ -44,7 +45,7 @@ class BrowserStateModifier { canReportSite = false, canSharePage = false, canAddBookmarks = false, - canAddFavorite = false, + addFavorite = HighlightableButton.Visible(enabled = false), addToHomeEnabled = false, canGoBack = false ) diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt index cdf1569dceaa..c5e863cfc48a 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabFragment.kt @@ -57,9 +57,13 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.commitNow import androidx.fragment.app.transaction import androidx.lifecycle.* -import androidx.recyclerview.widget.* +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion -import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion.* +import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion.AutoCompleteBookmarkSuggestion +import com.duckduckgo.app.autocomplete.api.AutoComplete.AutoCompleteSuggestion.AutoCompleteSearchSuggestion import com.duckduckgo.app.bookmarks.model.SavedSite import com.duckduckgo.app.bookmarks.ui.EditSavedSiteDialogFragment import com.duckduckgo.app.brokensite.BrokenSiteActivity @@ -86,7 +90,7 @@ import com.duckduckgo.app.browser.model.BasicAuthenticationRequest import com.duckduckgo.app.browser.model.LongPressTarget import com.duckduckgo.app.browser.omnibar.KeyboardAwareEditText import com.duckduckgo.app.browser.omnibar.OmnibarScrolling -import com.duckduckgo.app.browser.omnibar.QueryOrigin.* +import com.duckduckgo.app.browser.omnibar.QueryOrigin.FromAutocomplete import com.duckduckgo.app.browser.session.WebViewSessionStorage import com.duckduckgo.app.browser.shortcut.ShortcutBuilder import com.duckduckgo.app.browser.tabpreview.WebViewPreviewGenerator @@ -116,7 +120,7 @@ import com.duckduckgo.app.tabs.model.TabEntity import com.duckduckgo.app.tabs.ui.GridViewColumnCalculator import com.duckduckgo.app.tabs.ui.TabSwitcherActivity import com.duckduckgo.app.widget.ui.AddWidgetInstructionsActivity -import com.duckduckgo.widget.SearchWidgetLight +import com.duckduckgo.widget.SearchAndFavoritesWidget import com.google.android.material.snackbar.Snackbar import dagger.android.support.AndroidSupportInjection import kotlinx.android.synthetic.main.fragment_browser_tab.* @@ -231,6 +235,8 @@ class BrowserTabFragment : private val skipHome get() = requireArguments().getBoolean(SKIP_HOME_ARG) + private val favoritesOnboarding get() = requireArguments().getBoolean(FAVORITES_ONBOARDING_ARG, false) + private lateinit var popupMenu: BrowserPopupMenu private lateinit var autoCompleteSuggestionsAdapter: BrowserAutoCompleteSuggestionsAdapter @@ -252,7 +258,7 @@ class BrowserTabFragment : private val viewModel: BrowserTabViewModel by lazy { val viewModel = ViewModelProvider(this, viewModelFactory).get(BrowserTabViewModel::class.java) - viewModel.loadData(tabId, initialUrl, skipHome) + viewModel.loadData(tabId, initialUrl, skipHome, favoritesOnboarding) viewModel } @@ -998,7 +1004,7 @@ class BrowserTabFragment : private fun configureOmnibarQuickAccessGrid() { configureQuickAccessGridLayout(quickAccessSuggestionsRecyclerView) - omnibarQuickAccessAdapter = createQuickAccessAdapter { viewHolder -> + omnibarQuickAccessAdapter = createQuickAccessAdapter(originPixel = AppPixelName.FAVORITE_OMNIBAR_ITEM_PRESSED) { viewHolder -> quickAccessSuggestionsRecyclerView.enableAnimation() omnibarQuickAccessItemTouchHelper.startDrag(viewHolder) } @@ -1009,7 +1015,7 @@ class BrowserTabFragment : private fun configureHomeTabQuickAccessGrid() { configureQuickAccessGridLayout(quickAccessRecyclerView) - quickAccessAdapter = createQuickAccessAdapter { viewHolder -> + quickAccessAdapter = createQuickAccessAdapter(originPixel = AppPixelName.FAVORITE_HOMETAB_ITEM_PRESSED) { viewHolder -> quickAccessRecyclerView.enableAnimation() quickAccessItemTouchHelper.startDrag(viewHolder) } @@ -1034,10 +1040,13 @@ class BrowserTabFragment : } } - private fun createQuickAccessAdapter(onMoveListener: (RecyclerView.ViewHolder) -> Unit): FavoritesQuickAccessAdapter { + private fun createQuickAccessAdapter(originPixel: AppPixelName, onMoveListener: (RecyclerView.ViewHolder) -> Unit): FavoritesQuickAccessAdapter { return FavoritesQuickAccessAdapter( this, faviconManager, onMoveListener, - { viewModel.onUserSubmittedQuery(it.favorite.url) }, + { + pixel.fire(originPixel) + viewModel.onUserSubmittedQuery(it.favorite.url) + }, { viewModel.onEditSavedSiteRequested(it.favorite) }, { viewModel.onDeleteQuickAccessItemRequested(it.favorite) } ) @@ -1535,7 +1544,7 @@ class BrowserTabFragment : @SuppressLint("NewApi") private fun launchAddWidget() { val context = context ?: return - val provider = ComponentName(context, SearchWidgetLight::class.java) + val provider = ComponentName(context, SearchAndFavoritesWidget::class.java) AppWidgetManager.getInstance(context).requestPinAppWidget(provider, null, null) } @@ -1610,6 +1619,7 @@ class BrowserTabFragment : private const val TAB_ID_ARG = "TAB_ID_ARG" private const val URL_EXTRA_ARG = "URL_EXTRA_ARG" private const val SKIP_HOME_ARG = "SKIP_HOME_ARG" + private const val FAVORITES_ONBOARDING_ARG = "FAVORITES_ONBOARDING_ARG" private const val ADD_SAVED_SITE_FRAGMENT_TAG = "ADD_SAVED_SITE" private const val KEYBOARD_DELAY = 200L @@ -1646,6 +1656,15 @@ class BrowserTabFragment : fragment.arguments = args return fragment } + + fun newInstanceFavoritesOnboarding(tabId: String): BrowserTabFragment { + val fragment = BrowserTabFragment() + val args = Bundle() + args.putString(TAB_ID_ARG, tabId) + args.putBoolean(FAVORITES_ONBOARDING_ARG, true) + fragment.arguments = args + return fragment + } } inner class BrowserTabFragmentDecorator { @@ -1659,13 +1678,21 @@ class BrowserTabFragment : fun updateToolbarActionsVisibility(viewState: BrowserViewState) { tabsButton?.isVisible = viewState.showTabsButton - fireMenuButton?.isVisible = viewState.fireButton is FireButton.Visible - menuButton?.isVisible = viewState.showMenuButton + fireMenuButton?.isVisible = viewState.fireButton is HighlightableButton.Visible + menuButton?.isVisible = viewState.showMenuButton is HighlightableButton.Visible + + val targetView = if (viewState.showMenuButton.isHighlighted()) { + browserMenuImageView + } else if (viewState.fireButton.isHighlighted()) { + fireIconImageView + } else { + null + } // omnibar only scrollable when browser showing and the fire button is not promoted - if (viewState.fireButton.playPulseAnimation()) { + if (targetView != null) { omnibarScrolling.disableOmnibarScrolling(toolbarContainer) - playPulseAnimation() + playPulseAnimation(targetView) } else { if (viewState.browserShowing) { omnibarScrolling.enableOmnibarScrolling(toolbarContainer) @@ -1674,9 +1701,9 @@ class BrowserTabFragment : } } - private fun playPulseAnimation() { + private fun playPulseAnimation(targetView: View) { toolbarContainer.doOnLayout { - pulseAnimation.playOn(fireIconImageView) + pulseAnimation.playOn(targetView) } } @@ -1725,9 +1752,7 @@ class BrowserTabFragment : } } onMenuItemClicked(view.addFavoritePopupMenuItem) { - launch { - viewModel.onAddFavoriteMenuClicked() - } + viewModel.onAddFavoriteMenuClicked() } onMenuItemClicked(view.findInPageMenuItem) { pixel.fire(AppPixelName.MENU_ACTION_FIND_IN_PAGE_PRESSED) @@ -1756,13 +1781,16 @@ class BrowserTabFragment : onMenuItemClicked(view.newEmailAliasMenuItem) { viewModel.consumeAliasAndCopyToClipboard() } } browserMenu.setOnClickListener { + viewModel.onBrowserMenuClicked() hideKeyboardImmediately() launchTopAnchoredPopupMenu() } } private fun launchTopAnchoredPopupMenu() { - popupMenu.show(rootView, toolbar) + popupMenu.show(rootView, toolbar) { + viewModel.onBrowserMenuClosed() + } pixel.fire(AppPixelName.MENU_ACTION_POPUP_OPENED.pixelName) } @@ -1990,7 +2018,12 @@ class BrowserTabFragment : refreshPopupMenuItem.isEnabled = browserShowing newTabPopupMenuItem.isEnabled = browserShowing addBookmarksPopupMenuItem?.isEnabled = viewState.canAddBookmarks - addFavoritePopupMenuItem?.isEnabled = viewState.canAddFavorite + addFavoritePopupMenuItem?.isEnabled = viewState.addFavorite.isEnabled() + if (viewState.addFavorite.isHighlighted()) { + addFavoritePopupMenuItem.text = getString(R.string.addFavoriteMenuTitleHighlighted) + } else { + addFavoritePopupMenuItem.text = getString(R.string.addFavoriteMenuTitle) + } fireproofWebsitePopupMenuItem?.isEnabled = viewState.canFireproofSite fireproofWebsitePopupMenuItem?.isChecked = viewState.canFireproofSite && viewState.isFireproofWebsite sharePageMenuItem?.isEnabled = viewState.canSharePage @@ -2066,6 +2099,7 @@ class BrowserTabFragment : when (configuration) { is HomePanelCta -> showHomeCta(configuration, favorites) is DaxBubbleCta -> showDaxCta(configuration) + is BubbleCta -> showBubleCta(configuration) is DialogCta -> showDaxDialogCta(configuration) } } @@ -2095,6 +2129,14 @@ class BrowserTabFragment : viewModel.onCtaShown() } + private fun showBubleCta(configuration: BubbleCta) { + hideHomeBackground() + hideHomeCta() + configuration.showCta(daxCtaContainer) + newTabLayout.setOnClickListener { daxCtaContainer.dialogTextCta.finishAnimation() } + viewModel.onCtaShown() + } + private fun removeNewTabLayoutClickListener() { newTabLayout.setOnClickListener(null) } diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt index 8b39684a3a51..7e4e10752a3b 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserTabViewModel.kt @@ -102,6 +102,7 @@ import com.duckduckgo.app.settings.db.SettingsDataStore import com.duckduckgo.app.statistics.api.StatisticsUpdater import com.duckduckgo.app.statistics.pixels.Pixel import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter +import com.duckduckgo.app.statistics.pixels.Pixel.PixelParameter.FAVORITE_MENU_ITEM_STATE import com.duckduckgo.app.surrogates.SurrogateResponse import com.duckduckgo.app.survey.model.Survey import com.duckduckgo.app.tabs.model.TabEntity @@ -182,11 +183,11 @@ class BrowserTabViewModel( val showSearchIcon: Boolean = false, val showClearButton: Boolean = false, val showTabsButton: Boolean = true, - val fireButton: FireButton = FireButton.Visible(), - val showMenuButton: Boolean = true, + val fireButton: HighlightableButton = HighlightableButton.Visible(), + val showMenuButton: HighlightableButton = HighlightableButton.Visible(), val canSharePage: Boolean = false, val canAddBookmarks: Boolean = false, - val canAddFavorite: Boolean = false, + val addFavorite: HighlightableButton = HighlightableButton.Visible(enabled = false), val canFireproofSite: Boolean = false, val isFireproofWebsite: Boolean = false, val canGoBack: Boolean = false, @@ -200,13 +201,20 @@ class BrowserTabViewModel( val isEmailSignedIn: Boolean = false ) - sealed class FireButton { - data class Visible(val pulseAnimation: Boolean = false) : FireButton() - object Gone : FireButton() + sealed class HighlightableButton { + data class Visible(val enabled: Boolean = true, val highlighted: Boolean = false) : HighlightableButton() + object Gone : HighlightableButton() - fun playPulseAnimation(): Boolean { + fun isHighlighted(): Boolean { return when (this) { - is Visible -> this.pulseAnimation + is Visible -> this.highlighted + is Gone -> false + } + } + + fun isEnabled(): Boolean { + return when (this) { + is Visible -> this.enabled is Gone -> false } } @@ -338,6 +346,17 @@ class BrowserTabViewModel( val title: String? get() = site?.title + private var showFavoritesOnboarding = false + set(value) { + if (value != field) { + if (value) { + browserViewState.observeForever(favoritesOnboardingObserver) + } else { + browserViewState.removeObserver(favoritesOnboardingObserver) + } + } + field = value + } private var locationPermission: LocationPermission? = null private val locationPermissionMessages: MutableMap = mutableMapOf() private val locationPermissionSession: MutableMap = mutableMapOf() @@ -364,6 +383,14 @@ class BrowserTabViewModel( browserViewState.value = currentBrowserViewState().copy(isFireproofWebsite = isFireproofWebsite()) } + private val favoritesOnboardingObserver = Observer { state -> + val shouldShowAnimation = state.browserShowing + val menuButton = currentBrowserViewState().showMenuButton + if (menuButton is HighlightableButton.Visible && menuButton.highlighted != shouldShowAnimation) { + browserViewState.value = currentBrowserViewState().copy(showMenuButton = HighlightableButton.Visible(highlighted = shouldShowAnimation)) + } + } + private val fireproofDialogEventObserver = Observer { event -> command.value = when (event) { is Event.AskToDisableLoginDetection -> AskToDisableLoginDetection @@ -374,8 +401,8 @@ class BrowserTabViewModel( @ExperimentalCoroutinesApi private val fireButtonAnimation = Observer { shouldShowAnimation -> Timber.i("shouldShowAnimation $shouldShowAnimation") - if (currentBrowserViewState().fireButton is FireButton.Visible) { - browserViewState.value = currentBrowserViewState().copy(fireButton = FireButton.Visible(pulseAnimation = shouldShowAnimation)) + if (currentBrowserViewState().fireButton is HighlightableButton.Visible) { + browserViewState.value = currentBrowserViewState().copy(fireButton = HighlightableButton.Visible(highlighted = shouldShowAnimation)) } if (shouldShowAnimation) { @@ -432,9 +459,11 @@ class BrowserTabViewModel( }.launchIn(viewModelScope) } - fun loadData(tabId: String, initialUrl: String?, skipHome: Boolean) { + fun loadData(tabId: String, initialUrl: String?, skipHome: Boolean, favoritesOnboarding: Boolean) { + Timber.i("favoritesOnboarding loadData $initialUrl, $skipHome, $favoritesOnboarding") this.tabId = tabId this.skipHome = skipHome + this.showFavoritesOnboarding = favoritesOnboarding siteLiveData = tabRepository.retrieveSiteData(tabId) site = siteLiveData.value @@ -496,6 +525,7 @@ class BrowserTabViewModel( autoCompleteDisposable?.dispose() autoCompleteDisposable = null fireproofWebsiteState.removeObserver(fireproofWebsitesObserver) + browserViewState.removeObserver(favoritesOnboardingObserver) navigationAwareLoginDetector.loginEventLiveData.removeObserver(loginDetectionObserver) fireproofDialogsEventHandler.event.removeObserver(fireproofDialogEventObserver) showPulseAnimation.removeObserver(fireButtonAnimation) @@ -840,12 +870,17 @@ class BrowserTabViewModel( val domain = site?.domain val canWhitelist = domain != null val canFireproofSite = domain != null + val addFavorite = if (!currentBrowserViewState.addFavorite.isEnabled()) { + HighlightableButton.Visible(enabled = true) + } else { + currentBrowserViewState.addFavorite + } findInPageViewState.value = FindInPageViewState(visible = false, canFindInPage = true) browserViewState.value = currentBrowserViewState.copy( browserShowing = true, canAddBookmarks = true, - canAddFavorite = true, + addFavorite = addFavorite, addToHomeEnabled = true, addToHomeVisible = addToHomeCapabilityDetector.isAddToHomeSupported(), canSharePage = true, @@ -954,7 +989,7 @@ class BrowserTabViewModel( val currentBrowserViewState = currentBrowserViewState() browserViewState.value = currentBrowserViewState.copy( canAddBookmarks = false, - canAddFavorite = false, + addFavorite = HighlightableButton.Visible(enabled = false), addToHomeEnabled = false, addToHomeVisible = addToHomeCapabilityDetector.isAddToHomeSupported(), canSharePage = false, @@ -1303,11 +1338,15 @@ class BrowserTabViewModel( showSearchIcon = showSearchIcon, showTabsButton = showControls, fireButton = if (showControls) { - FireButton.Visible(pulseAnimation = showPulseAnimation.value ?: false) + HighlightableButton.Visible(highlighted = showPulseAnimation.value ?: false) } else { - FireButton.Gone + HighlightableButton.Gone + }, + showMenuButton = if (showControls) { + HighlightableButton.Visible() + } else { + HighlightableButton.Gone }, - showMenuButton = showControls, showClearButton = showClearButton, showDaxIcon = shouldShowDaxIcon(url, showPrivacyGrade) ) @@ -1338,18 +1377,26 @@ class BrowserTabViewModel( } } - suspend fun onAddFavoriteMenuClicked() { + fun onAddFavoriteMenuClicked() { val url = url ?: return val title = title ?: "" - withContext(dispatchers.io()) { - if (url.isNotBlank()) { - faviconManager.persistCachedFavicon(tabId, url) - favoritesRepository.insert(title = title, url = url) - } else null - }?.let { - withContext(dispatchers.main()) { - command.value = ShowSavedSiteAddedConfirmation(it) + val buttonHighlighted = currentBrowserViewState().addFavorite.isHighlighted() + pixel.fire( + AppPixelName.MENU_ACTION_ADD_FAVORITE_PRESSED.pixelName, + mapOf(FAVORITE_MENU_ITEM_STATE to buttonHighlighted.toString()) + ) + + viewModelScope.launch { + withContext(dispatchers.io()) { + if (url.isNotBlank()) { + faviconManager.persistCachedFavicon(tabId, url) + favoritesRepository.insert(title = title, url = url) + } else null + }?.let { + withContext(dispatchers.main()) { + command.value = ShowSavedSiteAddedConfirmation(it) + } } } } @@ -1664,6 +1711,26 @@ class BrowserTabViewModel( } } + fun onBrowserMenuClicked() { + Timber.i("favoritesOnboarding onBrowserMenuClicked") + val menuHighlighted = currentBrowserViewState().showMenuButton.isHighlighted() + if (menuHighlighted) { + this.showFavoritesOnboarding = false + browserViewState.value = currentBrowserViewState().copy(showMenuButton = HighlightableButton.Visible(highlighted = false), addFavorite = HighlightableButton.Visible(highlighted = true)) + } + } + + fun onBrowserMenuClosed() { + viewModelScope.launch { + Timber.i("favoritesOnboarding onBrowserMenuClosed") + if (currentBrowserViewState().addFavorite.isHighlighted()) { + browserViewState.value = currentBrowserViewState().copy( + addFavorite = HighlightableButton.Visible(highlighted = false) + ) + } + } + } + fun userRequestedOpeningNewTab() { command.value = GenerateWebViewPreviewImage command.value = LaunchNewTab @@ -1688,9 +1755,10 @@ class BrowserTabViewModel( } suspend fun refreshCta(locale: Locale = Locale.getDefault()): Cta? { + Timber.i("favoritesOnboarding: - refreshCta $showFavoritesOnboarding") if (currentGlobalLayoutState() is Browser) { val cta = withContext(dispatchers.io()) { - ctaViewModel.refreshCta(dispatchers.io(), currentBrowserViewState().browserShowing, siteLiveData.value, locale) + ctaViewModel.refreshCta(dispatchers.io(), currentBrowserViewState().browserShowing, siteLiveData.value, showFavoritesOnboarding, locale) } ctaViewState.value = currentCtaViewState().copy(cta = cta) return cta diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt index 643d99007c0b..770e670fa2f6 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserViewModel.kt @@ -151,6 +151,11 @@ class BrowserViewModel( } } + suspend fun onOpenFavoriteFromWidget(query: String) { + pixel.fire(AppPixelName.APP_FAVORITES_ITEM_WIDGET_LAUNCH) + tabRepository.selectByUrlOrNewTab(queryUrlConverter.convertQueryToUrl(query)) + } + suspend fun onTabsUpdated(tabs: List?) { if (tabs.isNullOrEmpty()) { Timber.i("Tabs list is null or empty; adding default tab") diff --git a/app/src/main/java/com/duckduckgo/app/browser/PulseAnimation.kt b/app/src/main/java/com/duckduckgo/app/browser/PulseAnimation.kt index 13fe09562070..35a5d5dabb92 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/PulseAnimation.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/PulseAnimation.kt @@ -76,12 +76,7 @@ class PulseAnimation(private val lifecycleOwner: LifecycleOwner) : LifecycleObse private fun startPulseAnimation(view: View) { if (!pulseAnimation.isRunning) { - val pulse = ObjectAnimator.ofPropertyValuesHolder( - view, - PropertyValuesHolder.ofFloat("scaleX", 0.95f, 1.9f), - PropertyValuesHolder.ofFloat("scaleY", 0.95f, 1.9f), - PropertyValuesHolder.ofFloat("alpha", 0f, 1f, 1f, 0.1f) - ) + val pulse = getPulseObjectAnimator(view) pulse.repeatCount = ObjectAnimator.INFINITE pulse.duration = 1100L @@ -92,6 +87,30 @@ class PulseAnimation(private val lifecycleOwner: LifecycleOwner) : LifecycleObse } } + private fun getPulseObjectAnimator(view: View): ObjectAnimator { + val width = view.width + val height = view.height + return if (width != height) { + val maxOf = maxOf(width, height) + val minOf = minOf(width, height) + val fromScaleSize = (maxOf * ANIM_INITIAL_SCALE) / minOf + val toScaleSize = fromScaleSize * 2 + ObjectAnimator.ofPropertyValuesHolder( + view, + PropertyValuesHolder.ofFloat("scaleX", fromScaleSize, toScaleSize), + PropertyValuesHolder.ofFloat("scaleY", fromScaleSize, toScaleSize), + PropertyValuesHolder.ofFloat("alpha", 0f, 1f, 1f, 0.1f) + ) + } else { + ObjectAnimator.ofPropertyValuesHolder( + view, + PropertyValuesHolder.ofFloat("scaleX", ANIM_INITIAL_SCALE, ANIM_FINAL_SCALE), + PropertyValuesHolder.ofFloat("scaleY", ANIM_INITIAL_SCALE, ANIM_FINAL_SCALE), + PropertyValuesHolder.ofFloat("alpha", 0f, 1f, 1f, 0.1f) + ) + } + } + private fun addHighlightView(targetView: View): View { if (targetView.parent !is ViewGroup) error("targetView parent should be ViewGroup") @@ -102,4 +121,9 @@ class PulseAnimation(private val lifecycleOwner: LifecycleOwner) : LifecycleObse (targetView.parent as ViewGroup).addView(highlightImageView, 0, layoutParams) return highlightImageView } + + companion object { + private const val ANIM_INITIAL_SCALE = 0.95f + private const val ANIM_FINAL_SCALE = ANIM_INITIAL_SCALE * 2 + } } diff --git a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconDownloader.kt b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconDownloader.kt index c258c64a8349..b86e6664202e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconDownloader.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconDownloader.kt @@ -21,6 +21,7 @@ import android.graphics.Bitmap import android.net.Uri import com.bumptech.glide.Glide import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.duckduckgo.app.global.DispatcherProvider import kotlinx.coroutines.withContext import java.io.File @@ -28,6 +29,7 @@ import javax.inject.Inject interface FaviconDownloader { suspend fun getFaviconFromDisk(file: File): Bitmap? + suspend fun getFaviconFromDisk(file: File, cornerRadius: Int, width: Int, height: Int): Bitmap? suspend fun getFaviconFromUrl(uri: Uri): Bitmap? } @@ -50,6 +52,21 @@ class GlideFaviconDownloader @Inject constructor( } } + override suspend fun getFaviconFromDisk(file: File, cornerRadius: Int, width: Int, height: Int): Bitmap? { + return withContext(dispatcherProvider.io()) { + return@withContext runCatching { + Glide.with(context) + .asBitmap() + .load(file) + .transform(RoundedCorners(cornerRadius)) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .skipMemoryCache(true) + .submit(width, height) + .get() + }.getOrNull() + } + } + override suspend fun getFaviconFromUrl(uri: Uri): Bitmap? { return withContext(dispatcherProvider.io()) { return@withContext runCatching { diff --git a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconManager.kt b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconManager.kt index 82a0dc9adf39..eb11d8319a7e 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconManager.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/favicon/FaviconManager.kt @@ -41,6 +41,7 @@ interface FaviconManager { suspend fun persistCachedFavicon(tabId: String, url: String) suspend fun loadToViewFromLocalOrFallback(tabId: String? = null, url: String, view: ImageView) suspend fun loadFromDisk(tabId: String?, url: String): Bitmap? + suspend fun loadFromDiskWithParams(tabId: String? = null, url: String, cornerRadius: Int, width: Int, height: Int): Bitmap? suspend fun deletePersistedFavicon(url: String) suspend fun deleteOldTempFavicon(tabId: String, path: String?) suspend fun deleteAllTemp() @@ -111,6 +112,22 @@ class DuckDuckGoFaviconManager constructor( } else null } + override suspend fun loadFromDiskWithParams(tabId: String?, url: String, cornerRadius: Int, width: Int, height: Int): Bitmap? { + val domain = url.extractDomain() ?: return null + + var cachedFavicon: File? = null + if (tabId != null) { + cachedFavicon = faviconPersister.faviconFile(FAVICON_TEMP_DIR, tabId, domain) + } + if (cachedFavicon == null) { + cachedFavicon = faviconPersister.faviconFile(FAVICON_PERSISTED_DIR, NO_SUBFOLDER, domain) + } + + return if (cachedFavicon != null) { + faviconDownloader.getFaviconFromDisk(cachedFavicon, cornerRadius, width, height) + } else null + } + override suspend fun loadToViewFromLocalOrFallback(tabId: String?, url: String, view: ImageView) { val bitmap = loadFromDisk(tabId, url) diff --git a/app/src/main/java/com/duckduckgo/app/cta/model/DismissedCta.kt b/app/src/main/java/com/duckduckgo/app/cta/model/DismissedCta.kt index 697f5ae94d5c..3aba4213e406 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/model/DismissedCta.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/model/DismissedCta.kt @@ -30,6 +30,7 @@ enum class CtaId { DAX_DIALOG_NETWORK, DAX_DIALOG_OTHER, DAX_END, + DAX_FAVORITES_ONBOARDING, DAX_FIRE_BUTTON_PULSE, UNKNOWN } diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt index e094f65d0e90..8e1aaf54a0a8 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/Cta.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.cta.ui import android.content.Context import android.net.Uri import android.view.View +import android.view.accessibility.AccessibilityNodeInfo import androidx.annotation.AnyRes import androidx.annotation.DrawableRes import androidx.annotation.StringRes @@ -257,6 +258,51 @@ sealed class DaxBubbleCta( ) } +sealed class BubbleCta( + override val ctaId: CtaId, + @StringRes open val description: Int, + override val shownPixel: Pixel.PixelName?, + override val okPixel: Pixel.PixelName?, + override val cancelPixel: Pixel.PixelName?, +) : Cta, ViewCta { + + override fun showCta(view: View) { + val daxText = view.context.getString(description) + view.show() + view.alpha = 1f + view.hiddenTextCta.text = daxText.html(view.context) + view.primaryCta.hide() + view.dialogTextCta.startTypingAnimation(daxText, true) + } + + override fun pixelCancelParameters(): Map = emptyMap() + + override fun pixelOkParameters(): Map = emptyMap() + + override fun pixelShownParameters(): Map = emptyMap() + + class DaxFavoritesOnboardingCta : BubbleCta( + CtaId.DAX_FAVORITES_ONBOARDING, + R.string.daxFavoritesOnboardingCtaText, + AppPixelName.FAVORITES_ONBOARDING_CTA_SHOWN, + null, + null + ) { + override fun showCta(view: View) { + super.showCta(view) + val accessibilityDelegate: View.AccessibilityDelegate = + object : View.AccessibilityDelegate() { + override fun onInitializeAccessibilityNodeInfo(v: View?, info: AccessibilityNodeInfo) { + super.onInitializeAccessibilityNodeInfo(v, info) + info.text = v?.context?.getString(R.string.daxFavoritesOnboardingCtaContentDescription) + } + } + // Using braille unicode inside textview (to simulate the overflow icon), override description for accessibility + view.dialogTextCta.accessibilityDelegate = accessibilityDelegate + } + } +} + sealed class DaxFireDialogCta( override val ctaId: CtaId, @StringRes open val description: Int, diff --git a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt index 4c2b2ce3b9e7..d412f22ec026 100644 --- a/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/cta/ui/CtaViewModel.kt @@ -163,7 +163,7 @@ class CtaViewModel @Inject constructor( } } - suspend fun refreshCta(dispatcher: CoroutineContext, isBrowserShowing: Boolean, site: Site? = null, locale: Locale = Locale.getDefault()): Cta? { + suspend fun refreshCta(dispatcher: CoroutineContext, isBrowserShowing: Boolean, site: Site? = null, favoritesOnboarding: Boolean = false, locale: Locale = Locale.getDefault()): Cta? { surveyCta(locale)?.let { return it } @@ -172,7 +172,12 @@ class CtaViewModel @Inject constructor( if (isBrowserShowing) { getBrowserCta(site) } else { - getHomeCta() + Timber.i("favoritesOnboarding: - refreshCta $favoritesOnboarding") + if (favoritesOnboarding) { + BubbleCta.DaxFavoritesOnboardingCta() + } else { + getHomeCta() + } } } } diff --git a/app/src/main/java/com/duckduckgo/app/di/AppComponent.kt b/app/src/main/java/com/duckduckgo/app/di/AppComponent.kt index 6b85cff8dc1d..75b840d4b117 100644 --- a/app/src/main/java/com/duckduckgo/app/di/AppComponent.kt +++ b/app/src/main/java/com/duckduckgo/app/di/AppComponent.kt @@ -32,6 +32,9 @@ import com.duckduckgo.app.surrogates.di.ResourceSurrogateModule import com.duckduckgo.app.trackerdetection.di.TrackerDetectionModule import com.duckduckgo.app.usage.di.AppUsageModule import com.duckduckgo.di.scopes.AppObjectGraph +import com.duckduckgo.widget.EmptyFavoritesWidgetService +import com.duckduckgo.widget.FavoritesWidgetService +import com.duckduckgo.widget.SearchAndFavoritesWidget import com.duckduckgo.widget.SearchWidget import com.squareup.anvil.annotations.MergeComponent import dagger.BindsInstance @@ -96,6 +99,12 @@ interface AppComponent : AndroidInjector { fun inject(searchWidget: SearchWidget) + fun inject(searchAndFavsWidget: SearchAndFavoritesWidget) + + fun inject(favoritesWidgetItemFactory: FavoritesWidgetService.FavoritesWidgetItemFactory) + + fun inject(emptyFavoritesWidgetItemFactory: EmptyFavoritesWidgetService.EmptyFavoritesWidgetItemFactory) + // accessor to Retrofit instance for test only only for test @Named("api") fun retrofit(): Retrofit diff --git a/app/src/main/java/com/duckduckgo/app/di/StoreModule.kt b/app/src/main/java/com/duckduckgo/app/di/StoreModule.kt index 473cb92f824b..f24ff4622a50 100644 --- a/app/src/main/java/com/duckduckgo/app/di/StoreModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/StoreModule.kt @@ -38,6 +38,9 @@ import com.duckduckgo.app.tabs.model.TabDataRepository import com.duckduckgo.app.tabs.model.TabRepository import com.duckduckgo.mobile.android.ui.store.ThemingDataStore import com.duckduckgo.mobile.android.ui.store.ThemingSharedPreferences +import com.duckduckgo.app.widget.FavoritesObserver +import com.duckduckgo.widget.AppWidgetThemePreferences +import com.duckduckgo.widget.WidgetPreferences import dagger.Binds import dagger.Module import dagger.multibindings.IntoSet @@ -86,4 +89,11 @@ abstract class StoreModule { @Binds @IntoSet abstract fun bindTabsDbSanitizerObserver(tabsDbSanitizer: TabsDbSanitizer): LifecycleObserver + + @Binds + @IntoSet + abstract fun bindFavoritesObserver(favoritesObserver: FavoritesObserver): LifecycleObserver + + @Binds + abstract fun bindWidgetPreferences(store: AppWidgetThemePreferences): WidgetPreferences } diff --git a/app/src/main/java/com/duckduckgo/app/di/WidgetModule.kt b/app/src/main/java/com/duckduckgo/app/di/WidgetModule.kt index 442158b5d8e5..5bdc308bdaa0 100644 --- a/app/src/main/java/com/duckduckgo/app/di/WidgetModule.kt +++ b/app/src/main/java/com/duckduckgo/app/di/WidgetModule.kt @@ -19,6 +19,7 @@ package com.duckduckgo.app.di import android.content.Context import com.duckduckgo.app.widget.ui.AppWidgetCapabilities import com.duckduckgo.app.widget.ui.WidgetCapabilities +import com.duckduckgo.widget.SearchAndFavoritesGridCalculator import dagger.Module import dagger.Provides import javax.inject.Singleton @@ -29,4 +30,7 @@ class WidgetModule { @Provides @Singleton fun widgetCapabilities(context: Context): WidgetCapabilities = AppWidgetCapabilities(context) + + @Provides + fun gridCalculator(): SearchAndFavoritesGridCalculator = SearchAndFavoritesGridCalculator() } diff --git a/app/src/main/java/com/duckduckgo/app/di/component/WidgetThemeConfigurationComponent.kt b/app/src/main/java/com/duckduckgo/app/di/component/WidgetThemeConfigurationComponent.kt new file mode 100644 index 000000000000..fd34800160eb --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/di/component/WidgetThemeConfigurationComponent.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.di.component + +import com.duckduckgo.app.di.ActivityScoped +import com.duckduckgo.di.scopes.ActivityObjectGraph +import com.duckduckgo.di.scopes.AppObjectGraph +import com.duckduckgo.app.WidgetThemeConfiguration +import com.squareup.anvil.annotations.ContributesTo +import com.squareup.anvil.annotations.MergeSubcomponent +import dagger.Binds +import dagger.Module +import dagger.Subcomponent +import dagger.android.AndroidInjector +import dagger.multibindings.ClassKey +import dagger.multibindings.IntoMap + +@ActivityScoped +@MergeSubcomponent( + scope = ActivityObjectGraph::class +) +interface WidgetThemeConfigurationComponent : AndroidInjector { + @Subcomponent.Factory + interface Factory : AndroidInjector.Factory +} + +@ContributesTo(AppObjectGraph::class) +interface WidgetThemeConfigurationComponentProvider { + fun provideWidgetThemeConfigurationComponentFactory(): WidgetThemeConfigurationComponent.Factory +} + +@Module +@ContributesTo(AppObjectGraph::class) +abstract class WidgetThemeConfigurationBindingModule { + @Binds + @IntoMap + @ClassKey(WidgetThemeConfiguration::class) + abstract fun bindWidgetThemeConfigurationComponentFactory(factory: WidgetThemeConfigurationComponent.Factory): AndroidInjector.Factory<*> +} diff --git a/app/src/main/java/com/duckduckgo/app/global/view/FaviconImageView.kt b/app/src/main/java/com/duckduckgo/app/global/view/FaviconImageView.kt index abdf03474225..9af9938f37af 100644 --- a/app/src/main/java/com/duckduckgo/app/global/view/FaviconImageView.kt +++ b/app/src/main/java/com/duckduckgo/app/global/view/FaviconImageView.kt @@ -61,13 +61,16 @@ fun ImageView.loadDefaultFavicon(domain: String) { this.setImageDrawable(generateDefaultDrawable(this.context, domain)) } -private fun generateDefaultDrawable(context: Context, domain: String): Drawable { +fun generateDefaultDrawable(context: Context, domain: String): Drawable { return object : Drawable() { private val baseHost: String = domain.toUri().baseHost ?: "" private val letter get() = baseHost.firstOrNull()?.toString()?.toUpperCase(Locale.getDefault()) ?: "" + private val faviconDefaultCornerRadius = context.resources.getDimension(R.dimen.savedSiteGridItemCornerRadiusFavicon) + private val faviconDefaultSize = context.resources.getDimension(R.dimen.savedSiteGridItemFavicon) + private val palette = listOf( "#94B3AF", "#727998", @@ -102,7 +105,8 @@ private fun generateDefaultDrawable(context: Context, domain: String): Drawable textPaint.textSize = (bounds.width() / 2).toFloat() val textWidth: Float = textPaint.measureText(letter) * 0.5f val textBaseLineHeight = textPaint.fontMetrics.ascent * -0.4f - canvas.drawRoundRect(0f, 0f, bounds.width().toFloat(), bounds.height().toFloat(), 10f, 10f, backgroundPaint) + val radius = (bounds.width() * faviconDefaultCornerRadius) / faviconDefaultSize + canvas.drawRoundRect(0f, 0f, bounds.width().toFloat(), bounds.height().toFloat(), radius, radius, backgroundPaint) canvas.drawText(letter, centerX - textWidth, centerY + textBaseLineHeight, textPaint) } 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 777df0701185..cf025305c4f0 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 @@ -82,6 +82,7 @@ fun View.hideKeyboard(): Boolean { fun Int.toDp(): Int = (this / Resources.getSystem().displayMetrics.density).toInt() fun Int.toPx(): Int = (this * Resources.getSystem().displayMetrics.density).toInt() fun Float.toPx(): Float = (this * Resources.getSystem().displayMetrics.density) +fun Float.toDp(): Float = (this / Resources.getSystem().displayMetrics.density) fun View.setAndPropagateUpFitsSystemWindows(enabled: Boolean = false) { fitsSystemWindows = enabled diff --git a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt index a6c2a17edeb4..45899f6fb79c 100644 --- a/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt +++ b/app/src/main/java/com/duckduckgo/app/pixels/AppPixelName.kt @@ -65,10 +65,25 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { WIDGET_LEGACY_CTA_LAUNCHED("m_wlc_l"), WIDGET_LEGACY_CTA_DISMISSED("m_wlc_d"), WIDGETS_ADDED(pixelName = "m_w_a"), + FAVORITES_WIDGETS_ADDED(pixelName = "m_sfw_a"), WIDGETS_DELETED(pixelName = "m_w_d"), + FAVORITE_WIDGET_CONFIGURATION_SHOWN(pixelName = "m_sfw_cs"), + FAVORITES_WIDGETS_LIGHT(pixelName = "m_sfw_l"), + FAVORITES_WIDGETS_DARK(pixelName = "m_sfw_dk"), + FAVORITES_WIDGETS_SYSTEM(pixelName = "m_sfw_sd"), + FAVORITES_WIDGETS_DELETED(pixelName = "m_sfw_d"), + + FAVORITES_ONBOARDING_CTA_SHOWN("m_fo_s"), + FAVORITE_OMNIBAR_ITEM_PRESSED("m_fav_o"), + FAVORITE_HOMETAB_ITEM_PRESSED("m_fav_ht"), + FAVORITE_BOOKMARKS_ITEM_PRESSED("m_fav_b"), + FAVORITE_SYSTEM_SEARCH_ITEM_PRESSED("m_fav_ss"), APP_NOTIFICATION_LAUNCH(pixelName = "m_n_l"), APP_WIDGET_LAUNCH(pixelName = "m_w_l"), + APP_FAVORITES_SEARCHBAR_WIDGET_LAUNCH(pixelName = "m_sfbw_l"), + APP_FAVORITES_ITEM_WIDGET_LAUNCH(pixelName = "m_sfiw_l"), + APP_EMPTY_VIEW_WIDGET_LAUNCH(pixelName = "m_sfew_l"), APP_ASSIST_LAUNCH(pixelName = "m_a_l"), APP_SYSTEM_SEARCH_BOX_LAUNCH(pixelName = "m_ssb_l"), INTERSTITIAL_LAUNCH_BROWSER_QUERY(pixelName = "m_i_lbq"), @@ -153,6 +168,7 @@ enum class AppPixelName(override val pixelName: String) : Pixel.PixelName { MENU_ACTION_NAVIGATE_FORWARD_PRESSED("m_nav_nf_p"), MENU_ACTION_NAVIGATE_BACK_PRESSED("m_nav_nb_p"), MENU_ACTION_ADD_BOOKMARK_PRESSED("m_nav_ab_p"), + MENU_ACTION_ADD_FAVORITE_PRESSED("m_nav_af_p"), MENU_ACTION_SHARE_PRESSED("m_nav_sh_p"), MENU_ACTION_FIND_IN_PAGE_PRESSED("m_nav_fip_p"), MENU_ACTION_ADD_TO_HOME_PRESSED("m_nav_ath_p"), 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 4faccd8bcd4b..b9b60dabdc65 100644 --- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt @@ -122,6 +122,7 @@ class SystemSearchActivity : DuckDuckGoActivity() { when { launchedFromAssist(intent) -> pixel.fire(AppPixelName.APP_ASSIST_LAUNCH) launchedFromWidget(intent) -> pixel.fire(AppPixelName.APP_WIDGET_LAUNCH) + launchedFromSearchWithFavsWidget(intent) -> pixel.fire(AppPixelName.APP_FAVORITES_SEARCHBAR_WIDGET_LAUNCH) launchedFromNotification(intent) -> pixel.fire(AppPixelName.APP_NOTIFICATION_LAUNCH) launchedFromSystemSearchBox(intent) -> pixel.fire(AppPixelName.APP_SYSTEM_SEARCH_BOX_LAUNCH) } @@ -371,6 +372,10 @@ class SystemSearchActivity : DuckDuckGoActivity() { return intent.getBooleanExtra(WIDGET_SEARCH_EXTRA, false) } + private fun launchedFromSearchWithFavsWidget(intent: Intent): Boolean { + return intent.getBooleanExtra(WIDGET_SEARCH_WITH_FAVS_EXTRA, false) + } + private fun launchedFromNotification(intent: Intent): Boolean { return intent.getBooleanExtra(NOTIFICATION_SEARCH_EXTRA, false) } @@ -378,6 +383,7 @@ class SystemSearchActivity : DuckDuckGoActivity() { companion object { const val NOTIFICATION_SEARCH_EXTRA = "NOTIFICATION_SEARCH_EXTRA" const val WIDGET_SEARCH_EXTRA = "WIDGET_SEARCH_EXTRA" + const val WIDGET_SEARCH_WITH_FAVS_EXTRA = "WIDGET_SEARCH_WITH_FAVS_EXTRA" const val NEW_SEARCH_ACTION = "com.duckduckgo.mobile.android.NEW_SEARCH" private const val QUICK_ACCESS_GRID_MAX_COLUMNS = 6 @@ -388,6 +394,13 @@ class SystemSearchActivity : DuckDuckGoActivity() { return intent } + fun fromFavWidget(context: Context): Intent { + val intent = Intent(context, SystemSearchActivity::class.java) + intent.putExtra(WIDGET_SEARCH_WITH_FAVS_EXTRA, true) + intent.putExtra(NOTIFICATION_SEARCH_EXTRA, false) + return intent + } + fun fromNotification(context: Context): Intent { val intent = Intent(context, SystemSearchActivity::class.java) intent.putExtra(WIDGET_SEARCH_EXTRA, false) 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 50fb251ff02e..a55159e1702e 100644 --- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt +++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchViewModel.kt @@ -288,6 +288,7 @@ class SystemSearchViewModel( } fun onQuickAccesItemClicked(it: FavoritesQuickAccessAdapter.QuickAccessFavorite) { + pixel.fire(FAVORITE_SYSTEM_SEARCH_ITEM_PRESSED) command.value = Command.LaunchBrowser(it.favorite.url) } diff --git a/app/src/main/java/com/duckduckgo/app/widget/FavoritesObserver.kt b/app/src/main/java/com/duckduckgo/app/widget/FavoritesObserver.kt new file mode 100644 index 000000000000..f84c4847ad67 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/app/widget/FavoritesObserver.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.app.widget + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleObserver +import androidx.lifecycle.OnLifecycleEvent +import com.duckduckgo.app.bookmarks.model.FavoritesRepository +import com.duckduckgo.app.browser.R +import com.duckduckgo.widget.SearchAndFavoritesWidget +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.collect +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FavoritesObserver @Inject constructor( + private val context: Context, + private val favoritesRepository: FavoritesRepository, + private val appCoroutineScope: CoroutineScope +) : LifecycleObserver { + + private val instance = AppWidgetManager.getInstance(context) + private val componentName = ComponentName(context, SearchAndFavoritesWidget::class.java) + + @OnLifecycleEvent(Lifecycle.Event.ON_START) + fun notifyWidgets() { + appCoroutineScope.launch { + favoritesRepository.favorites().collect { + instance.notifyAppWidgetViewDataChanged(instance.getAppWidgetIds(componentName), R.id.favoritesGrid) + instance.notifyAppWidgetViewDataChanged(instance.getAppWidgetIds(componentName), R.id.emptyfavoritesGrid) + } + } + } +} diff --git a/app/src/main/java/com/duckduckgo/app/widget/ui/WidgetCapabilities.kt b/app/src/main/java/com/duckduckgo/app/widget/ui/WidgetCapabilities.kt index c64736fb6971..07cd8b510f51 100644 --- a/app/src/main/java/com/duckduckgo/app/widget/ui/WidgetCapabilities.kt +++ b/app/src/main/java/com/duckduckgo/app/widget/ui/WidgetCapabilities.kt @@ -20,6 +20,7 @@ import android.appwidget.AppWidgetManager import android.content.ComponentName import android.content.Context import android.os.Build +import com.duckduckgo.widget.SearchAndFavoritesWidget import com.duckduckgo.widget.SearchWidget import com.duckduckgo.widget.SearchWidgetLight import javax.inject.Inject @@ -49,5 +50,6 @@ val Context.hasInstalledWidgets: Boolean val manager = AppWidgetManager.getInstance(this) val hasDarkWidget = manager.getAppWidgetIds(ComponentName(this, SearchWidget::class.java)).any() val hasLightWidget = manager.getAppWidgetIds(ComponentName(this, SearchWidgetLight::class.java)).any() - return hasDarkWidget || hasLightWidget + val hasSearchAndFavoritesWidget = manager.getAppWidgetIds(ComponentName(this, SearchAndFavoritesWidget::class.java)).any() + return hasDarkWidget || hasLightWidget || hasSearchAndFavoritesWidget } diff --git a/app/src/main/java/com/duckduckgo/widget/EmptyFavoritesWidgetService.kt b/app/src/main/java/com/duckduckgo/widget/EmptyFavoritesWidgetService.kt new file mode 100644 index 000000000000..1716218f9353 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/widget/EmptyFavoritesWidgetService.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.widget + +import android.content.Context +import android.content.Intent +import android.widget.RemoteViews +import android.widget.RemoteViewsService +import com.duckduckgo.app.bookmarks.model.FavoritesRepository +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.global.DuckDuckGoApplication +import javax.inject.Inject + +class EmptyFavoritesWidgetService : RemoteViewsService() { + + companion object { + const val MAX_ITEMS_EXTRAS = "MAX_ITEMS_EXTRAS" + } + + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { + return EmptyFavoritesWidgetItemFactory(this.applicationContext, intent) + } + + /** + * This RemoteViewsFactory will not render any item. It's used by is used for convenience to simplify executing background operations to show/hide empty widget CTA. + * If this RemoteViewsFactory count is 0, SearchAndFavoritesWidget R.id.emptyfavoritesGrid will show the configured EmptyView. + */ + class EmptyFavoritesWidgetItemFactory(val context: Context, intent: Intent) : RemoteViewsFactory { + + @Inject + lateinit var favoritesDataRepository: FavoritesRepository + + private var count = 0 + + override fun onCreate() { + inject(context) + } + + override fun onDataSetChanged() { + count = if (favoritesDataRepository.userHasFavorites()) 1 else 0 + } + + override fun onDestroy() { + } + + override fun getCount(): Int { + return count + } + + override fun getViewAt(position: Int): RemoteViews { + return RemoteViews(context.packageName, R.layout.empty_view) + } + + override fun getLoadingView(): RemoteViews { + return RemoteViews(context.packageName, R.layout.empty_view) + } + + override fun getViewTypeCount(): Int { + return 1 + } + + override fun getItemId(position: Int): Long { + return position.toLong() + } + + override fun hasStableIds(): Boolean { + return true + } + + private fun inject(context: Context) { + val application = context.applicationContext as DuckDuckGoApplication + application.daggerAppComponent.inject(this) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetService.kt b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetService.kt new file mode 100644 index 000000000000..a1eb188e1fd1 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/widget/FavoritesWidgetService.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.os.Bundle +import android.widget.RemoteViews +import android.widget.RemoteViewsService +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import com.duckduckgo.app.bookmarks.model.FavoritesRepository +import com.duckduckgo.app.browser.BrowserActivity +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.browser.favicon.FaviconManager +import com.duckduckgo.app.global.DuckDuckGoApplication +import com.duckduckgo.app.global.domain +import com.duckduckgo.app.global.view.generateDefaultDrawable +import kotlinx.coroutines.runBlocking +import javax.inject.Inject + +class FavoritesWidgetService : RemoteViewsService() { + + companion object { + const val MAX_ITEMS_EXTRAS = "MAX_ITEMS_EXTRAS" + const val THEME_EXTRAS = "THEME_EXTRAS" + } + + override fun onGetViewFactory(intent: Intent): RemoteViewsFactory { + return FavoritesWidgetItemFactory(this.applicationContext, intent) + } + + class FavoritesWidgetItemFactory(val context: Context, intent: Intent) : RemoteViewsFactory { + + private val theme = WidgetTheme.getThemeFrom(intent.extras?.getString(THEME_EXTRAS)) + + @Inject + lateinit var favoritesDataRepository: FavoritesRepository + + @Inject + lateinit var faviconManager: FaviconManager + + @Inject + lateinit var widgetPrefs: WidgetPreferences + + private val appWidgetId = intent.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + + private val faviconItemSize = context.resources.getDimension(R.dimen.savedSiteGridItemFavicon).toInt() + private val faviconItemCornerRadius = context.resources.getDimension(R.dimen.savedSiteGridItemCornerRadiusFavicon).toInt() + + private val maxItems: Int + get() { + return widgetPrefs.widgetSize(appWidgetId).let { it.first * it.second } + } + + data class WidgetFavorite(val title: String, val url: String, val bitmap: Bitmap?) + private val domains = mutableListOf() + + override fun onCreate() { + inject(context) + } + + override fun onDataSetChanged() { + val newList = favoritesDataRepository.favoritesSync().take(maxItems).map { + val bitmap = runBlocking { + faviconManager.loadFromDiskWithParams(url = it.url, cornerRadius = faviconItemCornerRadius, width = faviconItemSize, height = faviconItemSize) + ?: generateDefaultDrawable(context, it.url.extractDomain().orEmpty()).toBitmap(faviconItemSize, faviconItemSize) + } + WidgetFavorite(it.title, it.url, bitmap) + } + domains.clear() + domains.addAll(newList) + } + + override fun onDestroy() { + } + + override fun getCount(): Int { + return maxItems + } + + private fun String.extractDomain(): String? { + return if (this.startsWith("http")) { + this.toUri().domain() + } else { + "https://$this".extractDomain() + } + } + + override fun getViewAt(position: Int): RemoteViews { + val item = if (position >= domains.size) null else domains[position] + val remoteViews = RemoteViews(context.packageName, getItemLayout()) + if (item != null) { + if (item.bitmap != null) { + remoteViews.setImageViewBitmap(R.id.quickAccessFavicon, item.bitmap) + } + remoteViews.setTextViewText(R.id.quickAccessTitle, item.title) + configureClickListener(remoteViews, item.url) + } else { + remoteViews.setTextViewText(R.id.quickAccessTitle, "") + remoteViews.setImageViewResource(R.id.quickAccessFavicon, getEmptyBackgroundDrawable()) + } + + return remoteViews + } + + private fun getItemLayout(): Int { + return when (theme) { + WidgetTheme.LIGHT -> R.layout.view_favorite_widget_light_item + WidgetTheme.DARK -> R.layout.view_favorite_widget_dark_item + WidgetTheme.SYSTEM_DEFAULT -> R.layout.view_favorite_widget_daynight_item + } + } + + private fun getEmptyBackgroundDrawable(): Int { + return when (theme) { + WidgetTheme.LIGHT -> R.drawable.search_widget_favorite_favicon_light_background + WidgetTheme.DARK -> R.drawable.search_widget_favorite_favicon_dark_background + WidgetTheme.SYSTEM_DEFAULT -> R.drawable.search_widget_favorite_favicon_daynight_background + } + } + + private fun configureClickListener(remoteViews: RemoteViews, item: String) { + val bundle = Bundle() + bundle.putString(Intent.EXTRA_TEXT, item) + bundle.putBoolean(BrowserActivity.NEW_SEARCH_EXTRA, false) + bundle.putBoolean(BrowserActivity.LAUNCH_FROM_FAVORITES_WIDGET, true) + bundle.putBoolean(BrowserActivity.NOTIFY_DATA_CLEARED_EXTRA, false) + val intent = Intent() + intent.putExtras(bundle) + remoteViews.setOnClickFillInIntent(R.id.quickAccessFaviconContainer, intent) + } + + override fun getLoadingView(): RemoteViews { + return RemoteViews(context.packageName, getItemLayout()) + } + + override fun getViewTypeCount(): Int { + return 1 + } + + override fun getItemId(position: Int): Long { + return position.toLong() + } + + override fun hasStableIds(): Boolean { + return true + } + + private fun inject(context: Context) { + val application = context.applicationContext as DuckDuckGoApplication + application.daggerAppComponent.inject(this) + } + } +} diff --git a/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesGridCalculator.kt b/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesGridCalculator.kt new file mode 100644 index 000000000000..2d83f3dec960 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesGridCalculator.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.widget + +import android.content.Context +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.global.view.toDp +import timber.log.Timber + +class SearchAndFavoritesGridCalculator { + fun calculateColumns(context: Context, width: Int): Int { + val margins = context.resources.getDimension(R.dimen.searchWidgetFavoritesSideMargin).toDp() + val item = context.resources.getDimension(R.dimen.searchWidgetFavoriteItemContainerWidth).toDp() + val divider = context.resources.getDimension(R.dimen.searchWidgetFavoritesHorizontalSpacing).toDp() + var n = 2 + var totalSize = (n * item) + ((n - 1) * divider) + (margins * 2) + + Timber.i("SearchAndFavoritesWidget width n:$n $totalSize vs $width") + while (totalSize <= width) { + ++n + totalSize = (n * item) + ((n - 1) * divider) + (margins * 2) + Timber.i("SearchAndFavoritesWidget width n:$n $totalSize vs $width") + } + + return WIDGET_COLUMNS_MIN.coerceAtLeast(n - 1) + } + + fun calculateRows(context: Context, height: Int): Int { + val searchBar = context.resources.getDimension(R.dimen.searchWidgetSearchBarHeight).toDp() + val margins = context.resources.getDimension(R.dimen.searchWidgetFavoritesTopMargin).toDp() + + (context.resources.getDimension(R.dimen.searchWidgetPadding).toDp() * 2) + val item = context.resources.getDimension(R.dimen.searchWidgetFavoriteItemContainerHeight).toDp() + val divider = context.resources.getDimension(R.dimen.searchWidgetFavoritesVerticalSpacing).toDp() + var n = 1 + var totalSize = searchBar + (n * item) + ((n - 1) * divider) + margins + + Timber.i("SearchAndFavoritesWidget height n:$n $totalSize vs $height") + while (totalSize <= height) { + ++n + totalSize = searchBar + (n * item) + ((n - 1) * divider) + margins + Timber.i("SearchAndFavoritesWidget height n:$n $totalSize vs $height") + } + + var rows = n - 1 + rows = WIDGET_ROWS_MIN.coerceAtLeast(rows) + rows = WIDGET_ROWS_MAX.coerceAtMost(rows) + return rows + } + + companion object { + private const val WIDGET_COLUMNS_MIN = 2 + private const val WIDGET_ROWS_MAX = 4 + private const val WIDGET_ROWS_MIN = 1 + } +} diff --git a/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt b/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt new file mode 100644 index 000000000000..7f2d4b54cde4 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/widget/SearchAndFavoritesWidget.kt @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.widget + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.net.Uri +import android.os.Bundle +import android.view.View +import android.widget.RemoteViews +import com.duckduckgo.app.browser.BrowserActivity +import com.duckduckgo.app.browser.BrowserActivity.Companion.FAVORITES_ONBOARDING_EXTRA +import com.duckduckgo.app.browser.R +import com.duckduckgo.app.global.DuckDuckGoApplication +import com.duckduckgo.app.systemsearch.SystemSearchActivity +import com.duckduckgo.widget.FavoritesWidgetService.Companion.THEME_EXTRAS +import timber.log.Timber +import javax.inject.Inject + +enum class WidgetTheme { + LIGHT, + DARK, + SYSTEM_DEFAULT; + + companion object { + fun getThemeFrom(value: String?): WidgetTheme { + if (value.isNullOrEmpty()) return SYSTEM_DEFAULT + return runCatching { valueOf(value) }.getOrDefault(SYSTEM_DEFAULT) + } + } +} + +class SearchAndFavoritesWidget() : AppWidgetProvider() { + + companion object { + const val ACTION_FAVORITE = "com.duckduckgo.widget.actionFavorite" + } + + @Inject + lateinit var widgetPrefs: WidgetPreferences + + @Inject + lateinit var gridCalculator: SearchAndFavoritesGridCalculator + + private var layoutId: Int = R.layout.search_favorites_widget_daynight_auto + + override fun onReceive(context: Context, intent: Intent?) { + inject(context) + super.onReceive(context, intent) + } + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + Timber.i("SearchAndFavoritesWidget - onUpdate") + appWidgetIds.forEach { id -> + updateWidget(context, appWidgetManager, id, null) + } + super.onUpdate(context, appWidgetManager, appWidgetIds) + } + + override fun onAppWidgetOptionsChanged(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle) { + Timber.i("SearchAndFavoritesWidget - onAppWidgetOptionsChanged") + updateWidget(context, appWidgetManager, appWidgetId, newOptions) + super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + appWidgetIds.forEach { + widgetPrefs.removeWidgetSettings(it) + } + super.onDeleted(context, appWidgetIds) + } + + private fun updateWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle?) { + val widgetTheme = widgetPrefs.widgetTheme(appWidgetId) + Timber.i("SearchAndFavoritesWidget theme for $appWidgetId is $widgetTheme") + + val (columns, rows) = getCurrentWidgetSize(context, appWidgetManager.getAppWidgetOptions(appWidgetId), newOptions) + layoutId = getLayoutThemed(columns, widgetTheme) + widgetPrefs.storeWidgetSize(appWidgetId, columns, rows) + + val remoteViews = RemoteViews(context.packageName, layoutId) + + remoteViews.setViewVisibility(R.id.searchInputBox, if (columns == 2) View.INVISIBLE else View.VISIBLE) + remoteViews.setOnClickPendingIntent(R.id.widgetSearchBarContainer, buildPendingIntent(context)) + configureFavoritesGridView(context, appWidgetId, remoteViews, widgetTheme) + configureEmptyWidgetCta(context, appWidgetId, remoteViews, widgetTheme) + + appWidgetManager.updateAppWidget(appWidgetId, remoteViews) + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.favoritesGrid) + appWidgetManager.notifyAppWidgetViewDataChanged(appWidgetId, R.id.emptyfavoritesGrid) + } + + private fun getLayoutThemed(numColumns: Int, theme: WidgetTheme): Int { + // numcolumns method is not available for remoteViews. We rely on different xml to use different values on that attribute + return when (theme) { + WidgetTheme.LIGHT -> { + when (numColumns) { + 2 -> R.layout.search_favorites_widget_light_col2 + 3 -> R.layout.search_favorites_widget_light_col3 + 4 -> R.layout.search_favorites_widget_light_col4 + 5 -> R.layout.search_favorites_widget_light_col5 + 6 -> R.layout.search_favorites_widget_light_col6 + else -> R.layout.search_favorites_widget_light_auto + } + } + WidgetTheme.DARK -> { + when (numColumns) { + 2 -> R.layout.search_favorites_widget_dark_col2 + 3 -> R.layout.search_favorites_widget_dark_col3 + 4 -> R.layout.search_favorites_widget_dark_col4 + 5 -> R.layout.search_favorites_widget_dark_col5 + 6 -> R.layout.search_favorites_widget_dark_col6 + else -> R.layout.search_favorites_widget_dark_auto + } + } + WidgetTheme.SYSTEM_DEFAULT -> { + when (numColumns) { + 2 -> R.layout.search_favorites_widget_daynight_col2 + 3 -> R.layout.search_favorites_widget_daynight_col3 + 4 -> R.layout.search_favorites_widget_daynight_col4 + 5 -> R.layout.search_favorites_widget_daynight_col5 + 6 -> R.layout.search_favorites_widget_daynight_col6 + else -> R.layout.search_favorites_widget_daynight_auto + } + } + } + } + + private fun getCurrentWidgetSize(context: Context, appWidgetOptions: Bundle, newOptions: Bundle?): Pair { + var portraitWidth = appWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) + var landsWidth = appWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH) + var landsHeight = appWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT) + var portraitHeight = appWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) + + if (newOptions != null) { + portraitWidth = appWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) + landsWidth = appWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH) + landsHeight = appWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT) + portraitHeight = appWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT) + } + + val orientation = context.resources.configuration.orientation + val width = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { landsWidth } else { portraitWidth } + val height = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { landsHeight } else { portraitHeight } + + var columns = gridCalculator.calculateColumns(context, width) + var rows = gridCalculator.calculateRows(context, height) + + Timber.i("SearchAndFavoritesWidget $portraitWidth x $portraitHeight -> $columns x $rows") + return Pair(columns, rows) + } + + private fun configureFavoritesGridView(context: Context, appWidgetId: Int, remoteViews: RemoteViews, widgetTheme: WidgetTheme) { + val favoriteItemClickIntent = Intent(context, BrowserActivity::class.java) + val favoriteClickPendingIntent = PendingIntent.getActivity(context, 0, favoriteItemClickIntent, 0) + + val extras = Bundle() + extras.putInt(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + extras.putString(THEME_EXTRAS, widgetTheme.toString()) + + val adapterIntent = Intent(context, FavoritesWidgetService::class.java) + adapterIntent.putExtras(extras) + adapterIntent.data = Uri.parse(adapterIntent.toUri(Intent.URI_INTENT_SCHEME)) + remoteViews.setRemoteAdapter(R.id.favoritesGrid, adapterIntent) + remoteViews.setPendingIntentTemplate(R.id.favoritesGrid, favoriteClickPendingIntent) + } + + private fun configureEmptyWidgetCta(context: Context, appWidgetId: Int, remoteViews: RemoteViews, widgetTheme: WidgetTheme) { + remoteViews.setOnClickPendingIntent(R.id.emptyGridViewContainer, buildOnboardingPendingIntent(context, appWidgetId)) + + val extras = Bundle() + extras.putInt(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + extras.putString(THEME_EXTRAS, widgetTheme.toString()) + + val emptyAdapterIntent = Intent(context, EmptyFavoritesWidgetService::class.java) + emptyAdapterIntent.putExtras(extras) + emptyAdapterIntent.data = Uri.parse(emptyAdapterIntent.toUri(Intent.URI_INTENT_SCHEME)) + remoteViews.setEmptyView(R.id.emptyfavoritesGrid, R.id.emptyGridViewContainer) + remoteViews.setRemoteAdapter(R.id.emptyfavoritesGrid, emptyAdapterIntent) + } + + private fun buildPendingIntent(context: Context): PendingIntent { + val intent = SystemSearchActivity.fromFavWidget(context) + return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + + private fun buildOnboardingPendingIntent(context: Context, appWidgetId: Int): PendingIntent { + val intent = BrowserActivity.intent(context, newSearch = true) + intent.putExtra(FAVORITES_ONBOARDING_EXTRA, true) + return PendingIntent.getActivity(context, appWidgetId, intent, 0) + } + + private fun inject(context: Context) { + val application = context.applicationContext as DuckDuckGoApplication + application.daggerAppComponent.inject(this) + } +} diff --git a/app/src/main/java/com/duckduckgo/widget/SearchWidget.kt b/app/src/main/java/com/duckduckgo/widget/SearchWidget.kt index e40115720712..75f82ed2cf2c 100644 --- a/app/src/main/java/com/duckduckgo/widget/SearchWidget.kt +++ b/app/src/main/java/com/duckduckgo/widget/SearchWidget.kt @@ -21,6 +21,8 @@ import android.appwidget.AppWidgetManager import android.appwidget.AppWidgetProvider import android.content.Context import android.content.Intent +import android.os.Bundle +import android.view.View import android.widget.RemoteViews import com.duckduckgo.app.browser.R import com.duckduckgo.app.global.DuckDuckGoApplication @@ -65,16 +67,35 @@ open class SearchWidget(val layoutId: Int = R.layout.search_widget) : AppWidgetP override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { // There may be multiple widgets active, so update all of them for (appWidgetId in appWidgetIds) { - updateAppWidget(context, appWidgetManager, appWidgetId) + updateAppWidget(context, appWidgetManager, appWidgetId, null) } } - private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { + override fun onAppWidgetOptionsChanged(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle?) { + updateAppWidget(context, appWidgetManager, appWidgetId, newOptions) + super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions) + } + + private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, newOptions: Bundle?) { + val appWidgetOptions = appWidgetManager.getAppWidgetOptions(appWidgetId) + var portraitWidth = appWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) + + if (newOptions != null) { + portraitWidth = appWidgetOptions.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH) + } + + val shouldShowHint = shouldShowSearchBarHint(portraitWidth) + val views = RemoteViews(context.packageName, layoutId) + views.setViewVisibility(R.id.searchInputBox, if (shouldShowHint) View.VISIBLE else View.INVISIBLE) views.setOnClickPendingIntent(R.id.widgetContainer, buildPendingIntent(context)) appWidgetManager.updateAppWidget(appWidgetId, views) } + private fun shouldShowSearchBarHint(portraitWidth: Int): Boolean { + return portraitWidth > SEARCH_BAR_MIN_HINT_WIDTH_SIZE + } + private fun buildPendingIntent(context: Context): PendingIntent { val intent = SystemSearchActivity.fromWidget(context) return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) @@ -86,4 +107,8 @@ open class SearchWidget(val layoutId: Int = R.layout.search_widget) : AppWidgetP pixel.fire(WIDGETS_DELETED) } } + + companion object { + private const val SEARCH_BAR_MIN_HINT_WIDTH_SIZE = 168 + } } diff --git a/app/src/main/java/com/duckduckgo/widget/WidgetPreferences.kt b/app/src/main/java/com/duckduckgo/widget/WidgetPreferences.kt new file mode 100644 index 000000000000..63845cbce5c6 --- /dev/null +++ b/app/src/main/java/com/duckduckgo/widget/WidgetPreferences.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2021 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.widget + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import javax.inject.Inject + +interface WidgetPreferences { + fun widgetTheme(widgetId: Int): WidgetTheme + fun saveWidgetSelectedTheme(widgetId: Int, theme: String) + fun widgetSize(widgetId: Int): Pair + fun storeWidgetSize(widgetId: Int, columns: Int, rows: Int) + fun removeWidgetSettings(widgetId: Int) +} + +class AppWidgetThemePreferences @Inject constructor(private val context: Context) : WidgetPreferences { + + private val preferences: SharedPreferences + get() = context.getSharedPreferences(FILENAME, Context.MODE_PRIVATE) + + override fun widgetTheme(widgetId: Int): WidgetTheme { + return WidgetTheme.valueOf(preferences.getString(keyForWidgetTheme(widgetId), WidgetTheme.SYSTEM_DEFAULT.toString()) ?: WidgetTheme.SYSTEM_DEFAULT.toString()) + } + + override fun saveWidgetSelectedTheme(widgetId: Int, theme: String) { + preferences.edit(true) { + putString(keyForWidgetTheme(widgetId), theme) + } + } + + override fun widgetSize(widgetId: Int): Pair { + return Pair( + preferences.getInt("$SHARED_PREFS_WIDTH_KEY-$widgetId", 2), + preferences.getInt("$SHARED_PREFS_HEIGHT_KEY-$widgetId", 2) + ) + } + + override fun storeWidgetSize(widgetId: Int, columns: Int, rows: Int) { + preferences.edit(true) { + putInt("$SHARED_PREFS_WIDTH_KEY-$widgetId", columns) + putInt("$SHARED_PREFS_HEIGHT_KEY-$widgetId", rows) + } + } + + override fun removeWidgetSettings(widgetId: Int) { + preferences.edit(true) { + remove("$SHARED_PREFS_WIDTH_KEY-$widgetId") + remove("$SHARED_PREFS_HEIGHT_KEY-$widgetId") + remove("$SHARED_PREFS_THEME_KEY-$widgetId") + } + } + + private fun keyForWidgetTheme(widgetId: Int): String { + return "$SHARED_PREFS_THEME_KEY-$widgetId" + } + + companion object { + const val FILENAME = "com.duckduckgo.app.widget.theme" + const val SHARED_PREFS_THEME_KEY = "SelectedTheme" + const val SHARED_PREFS_WIDTH_KEY = "Width" + const val SHARED_PREFS_HEIGHT_KEY = "Height" + } +} diff --git a/app/src/main/res/drawable-nodpi/search_favorites_widget_dark_preview.png b/app/src/main/res/drawable-nodpi/search_favorites_widget_dark_preview.png new file mode 100644 index 000000000000..ad6a45b14b85 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/search_favorites_widget_dark_preview.png differ diff --git a/app/src/main/res/drawable-nodpi/search_favorites_widget_preview.png b/app/src/main/res/drawable-nodpi/search_favorites_widget_preview.png new file mode 100644 index 000000000000..3a750f2b5f7b Binary files /dev/null and b/app/src/main/res/drawable-nodpi/search_favorites_widget_preview.png differ diff --git a/app/src/main/res/drawable/ic_widget_loupe_silver_24dp.xml b/app/src/main/res/drawable/ic_loupe_24dp_dark.xml similarity index 61% rename from app/src/main/res/drawable/ic_widget_loupe_silver_24dp.xml rename to app/src/main/res/drawable/ic_loupe_24dp_dark.xml index 60efb1278d4d..c5d4955a75a4 100644 --- a/app/src/main/res/drawable/ic_widget_loupe_silver_24dp.xml +++ b/app/src/main/res/drawable/ic_loupe_24dp_dark.xml @@ -20,9 +20,7 @@ android:viewportWidth="24" android:viewportHeight="24"> - + android:fillColor="@color/white" + android:fillType="nonZero" + android:pathData="M14.774,16.051a6.702,6.702 0,0 1,-8.716 -10.13,6.7 6.7,0 0,1 10.13,8.716l3.446,3.447a1,1 0,0 1,-1.414 1.414l-3.446,-3.447zM7.33,14.124a4.9,4.9 0,1 0,6.93 -6.93,4.9 4.9,0 0,0 -6.93,6.93z" /> diff --git a/app/src/main/res/drawable/ic_widget_loupe_gunmetal_24dp.xml b/app/src/main/res/drawable/ic_loupe_24dp_daynight.xml similarity index 61% rename from app/src/main/res/drawable/ic_widget_loupe_gunmetal_24dp.xml rename to app/src/main/res/drawable/ic_loupe_24dp_daynight.xml index 265ff6a451f7..e6ac6344da9a 100644 --- a/app/src/main/res/drawable/ic_widget_loupe_gunmetal_24dp.xml +++ b/app/src/main/res/drawable/ic_loupe_24dp_daynight.xml @@ -20,9 +20,7 @@ android:viewportWidth="24" android:viewportHeight="24"> - + android:fillColor="@color/searchWidgetSearchBarLoupeColor" + android:fillType="nonZero" + android:pathData="M14.774,16.051a6.702,6.702 0,0 1,-8.716 -10.13,6.7 6.7,0 0,1 10.13,8.716l3.446,3.447a1,1 0,0 1,-1.414 1.414l-3.446,-3.447zM7.33,14.124a4.9,4.9 0,1 0,6.93 -6.93,4.9 4.9,0 0,0 -6.93,6.93z" /> diff --git a/app/src/main/res/drawable/ic_loupe_24dp_light.xml b/app/src/main/res/drawable/ic_loupe_24dp_light.xml new file mode 100644 index 000000000000..08340023d751 --- /dev/null +++ b/app/src/main/res/drawable/ic_loupe_24dp_light.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/app/src/main/res/drawable/search_favorites_widget_search_bar_background_light.xml b/app/src/main/res/drawable/search_favorites_widget_search_bar_background_light.xml new file mode 100644 index 000000000000..1e8ba266a6a9 --- /dev/null +++ b/app/src/main/res/drawable/search_favorites_widget_search_bar_background_light.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_widget_background.xml b/app/src/main/res/drawable/search_widget_background.xml index bbafc6da9268..abb72bfd0e49 100644 --- a/app/src/main/res/drawable/search_widget_background.xml +++ b/app/src/main/res/drawable/search_widget_background.xml @@ -15,6 +15,6 @@ --> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_widget_background_daynight.xml b/app/src/main/res/drawable/search_widget_background_daynight.xml new file mode 100644 index 000000000000..3c17eb462b71 --- /dev/null +++ b/app/src/main/res/drawable/search_widget_background_daynight.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_widget_background_light.xml b/app/src/main/res/drawable/search_widget_background_light.xml index 3a67d8ff71eb..f46ee7ed9959 100644 --- a/app/src/main/res/drawable/search_widget_background_light.xml +++ b/app/src/main/res/drawable/search_widget_background_light.xml @@ -15,6 +15,6 @@ --> - + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_widget_favorite_favicon_dark_background.xml b/app/src/main/res/drawable/search_widget_favorite_favicon_dark_background.xml new file mode 100644 index 000000000000..be208cf4e59e --- /dev/null +++ b/app/src/main/res/drawable/search_widget_favorite_favicon_dark_background.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_widget_favorite_favicon_daynight_background.xml b/app/src/main/res/drawable/search_widget_favorite_favicon_daynight_background.xml new file mode 100644 index 000000000000..a3243eac6b38 --- /dev/null +++ b/app/src/main/res/drawable/search_widget_favorite_favicon_daynight_background.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_widget_favorite_favicon_light_background.xml b/app/src/main/res/drawable/search_widget_favorite_favicon_light_background.xml new file mode 100644 index 000000000000..50220dd7e709 --- /dev/null +++ b/app/src/main/res/drawable/search_widget_favorite_favicon_light_background.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_widget_favorites_dark_background.xml b/app/src/main/res/drawable/search_widget_favorites_dark_background.xml new file mode 100644 index 000000000000..6ee2f183a609 --- /dev/null +++ b/app/src/main/res/drawable/search_widget_favorites_dark_background.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_widget_favorites_daynight_background.xml b/app/src/main/res/drawable/search_widget_favorites_daynight_background.xml new file mode 100644 index 000000000000..7cb30405a952 --- /dev/null +++ b/app/src/main/res/drawable/search_widget_favorites_daynight_background.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/search_widget_favorites_light_background.xml b/app/src/main/res/drawable/search_widget_favorites_light_background.xml new file mode 100644 index 000000000000..01789c4ad5cd --- /dev/null +++ b/app/src/main/res/drawable/search_widget_favorites_light_background.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_widget_configuration.xml b/app/src/main/res/layout/activity_widget_configuration.xml new file mode 100644 index 000000000000..9264f9e2d162 --- /dev/null +++ b/app/src/main/res/layout/activity_widget_configuration.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + +