diff --git a/app/src/main/java/com/prirai/android/nira/BrowserActivity.kt b/app/src/main/java/com/prirai/android/nira/BrowserActivity.kt index 4a610c7..08f3c17 100644 --- a/app/src/main/java/com/prirai/android/nira/BrowserActivity.kt +++ b/app/src/main/java/com/prirai/android/nira/BrowserActivity.kt @@ -41,6 +41,7 @@ import mozilla.components.browser.state.action.AppLifecycleAction import mozilla.components.browser.state.search.SearchEngine import mozilla.components.browser.state.state.SessionState import mozilla.components.browser.state.state.WebExtensionState +import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine import mozilla.components.concept.engine.EngineSession import mozilla.components.concept.engine.EngineView import mozilla.components.feature.contextmenu.ext.DefaultSelectionActionDelegate @@ -505,10 +506,10 @@ open class BrowserActivity : LocaleAwareAppCompatActivity(), ComponentCallbacks2 components.store.state.tabs.find { it.id == id } } val isPrivateMode = selectedTab?.content?.private ?: browsingModeManager.mode.isPrivate + val resolvedEngine = engine ?: resolveSearchEngine(isPrivateMode) - if ((!forceSearch && searchTermOrURL.isUrl()) || engine == null) { + if (resolvedEngine == null || (!forceSearch && searchTermOrURL.isUrl())) { if (newTab) { - // Determine contextId for proper tab grouping and visibility val contextId = getContextIdForNewTab(isPrivateMode, selectedTab) components.tabsUseCases.addTab.invoke( searchTermOrURL.toNormalizedUrl(), @@ -521,21 +522,28 @@ open class BrowserActivity : LocaleAwareAppCompatActivity(), ComponentCallbacks2 } } else { if (newTab) { - // For search, use newTabSearch but set contextId via middleware after tab creation - // The ProfileMiddleware will set contextId based on private mode components.searchUseCases.newTabSearch .invoke( searchTermOrURL, SessionState.Source.Internal.UserEntered, isPrivateMode, - searchEngine = engine + searchEngine = resolvedEngine ) } else { - components.searchUseCases.defaultSearch.invoke(searchTermOrURL, engine) + components.searchUseCases.defaultSearch.invoke(searchTermOrURL, resolvedEngine) } } } + private fun resolveSearchEngine(isPrivateMode: Boolean): SearchEngine? { + components.store.state.search.selectedOrDefaultSearchEngine(isPrivateMode)?.let { return it } + return try { + SearchEngineList(this).getSelectedEngine(UserPreferences(this)) + } catch (_: Exception) { + null + } + } + /** * Get the appropriate contextId for new tabs based on browsing mode and current profile. * This ensures tabs are properly grouped and visible in tab bar/sheet. diff --git a/app/src/main/java/com/prirai/android/nira/browser/SearchEngineList.kt b/app/src/main/java/com/prirai/android/nira/browser/SearchEngineList.kt index 86ed9fe..314f0a5 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/SearchEngineList.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/SearchEngineList.kt @@ -4,8 +4,9 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import androidx.core.content.ContextCompat -import mozilla.components.browser.state.search.SearchEngine import androidx.core.graphics.createBitmap +import com.prirai.android.nira.preferences.UserPreferences +import mozilla.components.browser.state.search.SearchEngine class SearchEngineList(private val context: Context) { @@ -22,6 +23,21 @@ class SearchEngineList(private val context: Context) { return bitmap } + fun getSelectedEngine(preferences: UserPreferences): SearchEngine { + if (preferences.customSearchEngine && preferences.customSearchEngineURL.isNotBlank()) { + return SearchEngine( + id = "custom", + name = "Custom Search", + icon = getIconBitmap(com.prirai.android.nira.R.drawable.ic_search), + type = SearchEngine.Type.CUSTOM, + resultUrls = listOf(preferences.customSearchEngineURL) + ) + } + val engines = getEngines() + val index = preferences.searchEngineChoice.coerceIn(engines.indices) + return engines[index] + } + fun getEngines(): List { return listOf( SearchEngine( @@ -29,24 +45,24 @@ class SearchEngineList(private val context: Context) { name = "Google", icon = getIconBitmap(com.prirai.android.nira.R.drawable.google), type = SearchEngine.Type.BUNDLED, - resultUrls = listOf("https://www.google.com/?q={searchTerms}"), - suggestUrl = "https://www.google.com/" + resultUrls = listOf("https://www.google.com/search?q={searchTerms}"), + suggestUrl = "https://www.google.com/complete/search?client=firefox&q={searchTerms}" ), SearchEngine( id = "ddg", name = "DuckDuckGo", icon = getIconBitmap(com.prirai.android.nira.R.drawable.duckduckgo), type = SearchEngine.Type.BUNDLED, - resultUrls = listOf("https://www.duckduckgo.com/?q={searchTerms}"), - suggestUrl = "https://www.duckduckgo.com/" + resultUrls = listOf("https://duckduckgo.com/?q={searchTerms}"), + suggestUrl = "https://ac.duckduckgo.com/ac/?q={searchTerms}&type=list" ), SearchEngine( id = "bing", name = "Bing", icon = getIconBitmap(com.prirai.android.nira.R.drawable.microsoft_bing), type = SearchEngine.Type.BUNDLED, - resultUrls = listOf("https://www.bing.com/?q={searchTerms}"), - suggestUrl = "https://www.bing.com/" + resultUrls = listOf("https://www.bing.com/search?q={searchTerms}"), + suggestUrl = "https://www.bing.com/osjson.aspx?query={searchTerms}" ), SearchEngine( id = "baidu", @@ -54,7 +70,7 @@ class SearchEngineList(private val context: Context) { icon = getIconBitmap(com.prirai.android.nira.R.drawable.baidu), type = SearchEngine.Type.CUSTOM, resultUrls = listOf("https://www.baidu.com/s?wd={searchTerms}"), - suggestUrl = "https://www.baidu.com/" + suggestUrl = "https://suggestion.baidu.com/su?wd={searchTerms}&action=opensearch" ), SearchEngine( id = "yandex", @@ -62,7 +78,7 @@ class SearchEngineList(private val context: Context) { icon = getIconBitmap(com.prirai.android.nira.R.drawable.yandex), type = SearchEngine.Type.CUSTOM, resultUrls = listOf("https://yandex.com/search/?text={searchTerms}"), - suggestUrl = "https://www.yandex.com/" + suggestUrl = "https://suggest.yandex.com/suggest-ff.cgi?part={searchTerms}" ), SearchEngine( id = "naver", @@ -70,21 +86,23 @@ class SearchEngineList(private val context: Context) { icon = getIconBitmap(com.prirai.android.nira.R.drawable.naver), type = SearchEngine.Type.CUSTOM, resultUrls = listOf("https://m.search.naver.com/search.naver?query={searchTerms}"), - suggestUrl = "https://www.naver.com/" + suggestUrl = "https://ac.search.naver.com/nx/ac?q={searchTerms}&con=0&frm=nx&ans=2&r_format=json&r_enc=UTF-8&q_enc=UTF-8&st=100" ), SearchEngine( id = "qwant", name = "Qwant", icon = getIconBitmap(com.prirai.android.nira.R.drawable.qwant), type = SearchEngine.Type.CUSTOM, - resultUrls = listOf("https://qwant.com/?q={searchTerms}") + resultUrls = listOf("https://www.qwant.com/?q={searchTerms}"), + suggestUrl = "https://api.qwant.com/v3/suggest?q={searchTerms}" ), SearchEngine( id = "startpage", name = "StartPage", icon = getIconBitmap(com.prirai.android.nira.R.drawable.startpage), type = SearchEngine.Type.CUSTOM, - resultUrls = listOf("https://startpage.com/sp/search?query={searchTerms}") + resultUrls = listOf("https://www.startpage.com/sp/search?query={searchTerms}"), + suggestUrl = "https://www.startpage.com/suggestions?q={searchTerms}&format=opensearch" ) ) } diff --git a/app/src/main/java/com/prirai/android/nira/browser/tabs/compose/TabBarCompose.kt b/app/src/main/java/com/prirai/android/nira/browser/tabs/compose/TabBarCompose.kt index 8f4b8e0..4e64ebc 100644 --- a/app/src/main/java/com/prirai/android/nira/browser/tabs/compose/TabBarCompose.kt +++ b/app/src/main/java/com/prirai/android/nira/browser/tabs/compose/TabBarCompose.kt @@ -93,13 +93,12 @@ fun TabBarCompose( buildBarItems(order, tabs) } - // Track initial load to prevent animated transitions showing both states - var isInitialLoad by remember { mutableStateOf(true) } - LaunchedEffect(order) { - if (order != null && isInitialLoad) { - // Small delay to ensure order is fully processed - kotlinx.coroutines.delay(50) - isInitialLoad = false + val uniqueItems = remember(items) { + items.distinctBy { item -> + when (item) { + is BarItem.SingleTab -> "tab-${item.id}" + is BarItem.Group -> "group-${item.id}" + } } } @@ -204,7 +203,15 @@ fun TabBarCompose( horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth() ) { - itemsIndexed(items, key = { _, item -> item.id }) { index, item -> + itemsIndexed( + uniqueItems, + key = { _, item -> + when (item) { + is BarItem.SingleTab -> "tab-${item.id}" + is BarItem.Group -> "group-${item.id}" + } + } + ) { index, item -> when (item) { is BarItem.SingleTab -> { var offsetY by remember { mutableStateOf(0f) } @@ -212,7 +219,6 @@ fun TabBarCompose( Box( modifier = Modifier - .then(if (!isInitialLoad) Modifier.animateItem() else Modifier) .draggableItem( itemType = DraggableItemType.Tab(item.tab.id), coordinator = coordinator @@ -302,7 +308,6 @@ fun TabBarCompose( Box( modifier = Modifier - .then(if (!isInitialLoad) Modifier.animateItem() else Modifier) .draggableItem( itemType = DraggableItemType.Group(item.groupId), coordinator = coordinator @@ -393,12 +398,11 @@ fun TabBarCompose( } // Add invisible divider after each item for drag-and-drop (except the last one) - if (index < items.size - 1) { + if (index < uniqueItems.size - 1) { TabDivider( id = "divider_${index + 1}", coordinator = coordinator, - position = index + 1, - modifier = if (!isInitialLoad) Modifier.animateItem() else Modifier + position = index + 1 ) } } diff --git a/app/src/main/java/com/prirai/android/nira/history/HistoryActivity.kt b/app/src/main/java/com/prirai/android/nira/history/HistoryActivity.kt index c3def36..8d557b4 100644 --- a/app/src/main/java/com/prirai/android/nira/history/HistoryActivity.kt +++ b/app/src/main/java/com/prirai/android/nira/history/HistoryActivity.kt @@ -68,15 +68,15 @@ class HistoryActivity : AppCompatActivity(), SearchView.OnQueryTextListener { recyclerView, object : HistoryRecyclerViewItemTouchListener.OnItemClickListener { override fun onItemClick(view: View?, position: Int) { + val visit = (recyclerView.adapter as HistoryItemRecyclerViewAdapter).getVisitAt(position) + ?: return onBackPressedDispatcher.onBackPressed() - components.sessionUseCases.loadUrl( - (recyclerView.adapter as HistoryItemRecyclerViewAdapter).getItem( - position - ).url - ) + components.sessionUseCases.loadUrl(visit.url) } override fun onLongItemClick(view: View?, position: Int) { + val adapter = recyclerView.adapter as HistoryItemRecyclerViewAdapter + if (adapter.getVisitAt(position) == null) return val items = arrayOf( resources.getString(R.string.open_new), resources.getString(R.string.open_new_private), diff --git a/app/src/main/java/com/prirai/android/nira/history/HistoryItemRecyclerViewAdapter.kt b/app/src/main/java/com/prirai/android/nira/history/HistoryItemRecyclerViewAdapter.kt index 76db14b..b2d14b1 100644 --- a/app/src/main/java/com/prirai/android/nira/history/HistoryItemRecyclerViewAdapter.kt +++ b/app/src/main/java/com/prirai/android/nira/history/HistoryItemRecyclerViewAdapter.kt @@ -6,24 +6,20 @@ import android.view.ViewGroup import android.widget.Filter import android.widget.ImageView import android.widget.TextView -import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.card.MaterialCardView import com.prirai.android.nira.R -import com.prirai.android.nira.ext.components +import com.prirai.android.nira.utils.FaviconLoader import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import mozilla.components.concept.storage.VisitInfo import mozilla.components.support.ktx.kotlin.tryGetHostFromUrl -import java.text.DateFormat.getDateTimeInstance -import java.util.ArrayList -import java.util.Date -import java.util.Locale open class HistoryItemRecyclerViewAdapter( private var values: List -) - : RecyclerView.Adapter() { +) : RecyclerView.Adapter() { companion object { private const val TYPE_HEADER = 0 @@ -31,65 +27,48 @@ open class HistoryItemRecyclerViewAdapter( } private var historyItems: List = HistorySectionHelper.groupHistoryByDate(values) - lateinit var filtered: MutableList - lateinit var oldList: MutableList + private var oldList: List = values - open fun getFilter(): Filter? { + open fun getFilter(): Filter { return object : Filter() { override fun performFiltering(charSequence: CharSequence): FilterResults { - val charString = charSequence.toString() - - filtered = if (charString.isEmpty()) { + val query = charSequence.toString() + val filtered = if (query.isEmpty()) { oldList } else { - val filteredList: MutableList = ArrayList() - for (row in oldList) { - if (row.url.lowercase(Locale.getDefault()).contains(charString.lowercase(Locale.getDefault())) || row.title?.lowercase( - Locale.getDefault() - ) - ?.contains( - charString.lowercase(Locale.getDefault()) - ) == true) { - filteredList.add(row) - } + oldList.filter { row -> + row.url.contains(query, ignoreCase = true) || + row.title?.contains(query, ignoreCase = true) == true } - filteredList } - val filterResults = FilterResults() - filterResults.values = filtered - return filterResults + return FilterResults().apply { values = filtered } } + @Suppress("UNCHECKED_CAST") override fun publishResults(charSequence: CharSequence, filterResults: FilterResults) { - values = filterResults.values as MutableList + values = filterResults.values as List historyItems = HistorySectionHelper.groupHistoryByDate(values) notifyDataSetChanged() } } } + fun getVisitAt(position: Int): VisitInfo? { + return (historyItems.getOrNull(position) as? HistoryItem.Visit)?.visitInfo + } + fun getItem(position: Int): VisitInfo { - return when (val item = historyItems[position]) { - is HistoryItem.Visit -> item.visitInfo - is HistoryItem.Header -> values[0] - } + return getVisitAt(position) ?: values.first() } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { - oldList = values as MutableList return when (viewType) { - TYPE_HEADER -> { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.history_section_header, parent, false) - HeaderViewHolder(view) - } - else -> { - val view = LayoutInflater.from(parent.context) - .inflate(R.layout.history_list_item, parent, false) - view.alpha = 0f - view.animate().alpha(1f).setDuration(200).setStartDelay((Math.min(5, viewType) * 50).toLong()).start() - ViewHolder(view) - } + TYPE_HEADER -> HeaderViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.history_section_header, parent, false) + ) + else -> ViewHolder( + LayoutInflater.from(parent.context).inflate(R.layout.history_list_item, parent, false) + ) } } @@ -101,32 +80,29 @@ open class HistoryItemRecyclerViewAdapter( is HistoryItem.Visit -> { holder as ViewHolder val visitInfo = item.visitInfo - val title = visitInfo.title ?.takeIf(String::isNotEmpty) ?: visitInfo.url.tryGetHostFromUrl() - val relativeTime = HistoryTimeFormatter.getRelativeTimeString(holder.itemView.context, visitInfo.visitTime) - - holder.titleView.text = title - holder.urlView.text = visitInfo.url - holder.timeView.text = relativeTime - - // Load favicon from cache - CoroutineScope(Dispatchers.Main).launch { - val context = holder.itemView.context - val faviconCache = context.components.faviconCache - val cachedIcon = faviconCache.loadFavicon(visitInfo.url) - if (cachedIcon != null) { - holder.faviconView.setImageBitmap(cachedIcon) - } else { - holder.faviconView.setImageDrawable( - ContextCompat.getDrawable(context, R.drawable.ic_baseline_history) - ) - } - } + holder.titleView.text = visitInfo.title?.takeIf(String::isNotEmpty) + ?: visitInfo.url.tryGetHostFromUrl() + holder.timeView.text = HistoryTimeFormatter.getRelativeTimeString( + holder.itemView.context, + visitInfo.visitTime + ) + applyCardShape(holder.cardView, position) + holder.divider.visibility = if (isLastInGroup(position)) View.GONE else View.VISIBLE + holder.bindFavicon(visitInfo.url) } } } + override fun onViewRecycled(holder: RecyclerView.ViewHolder) { + if (holder is ViewHolder) { + holder.faviconJob?.cancel() + holder.faviconView.setImageResource(R.drawable.ic_baseline_history) + } + super.onViewRecycled(holder) + } + override fun getItemCount(): Int = historyItems.size - + override fun getItemViewType(position: Int): Int { return when (historyItems[position]) { is HistoryItem.Header -> TYPE_HEADER @@ -134,14 +110,49 @@ open class HistoryItemRecyclerViewAdapter( } } + private fun isFirstInGroup(position: Int): Boolean { + return historyItems.getOrNull(position - 1) is HistoryItem.Header + } + + private fun isLastInGroup(position: Int): Boolean { + val next = historyItems.getOrNull(position + 1) + return next == null || next is HistoryItem.Header + } + + private fun applyCardShape(cardView: MaterialCardView, position: Int) { + val radius = cardView.context.resources.getDimension(R.dimen.search_result_corner_radius) + val first = isFirstInGroup(position) + val last = isLastInGroup(position) + cardView.shapeAppearanceModel = cardView.shapeAppearanceModel.toBuilder() + .setTopLeftCornerSize(if (first) radius else 0f) + .setTopRightCornerSize(if (first) radius else 0f) + .setBottomLeftCornerSize(if (last) radius else 0f) + .setBottomRightCornerSize(if (last) radius else 0f) + .build() + } + class HeaderViewHolder(view: View) : RecyclerView.ViewHolder(view) { val titleView: TextView = view.findViewById(R.id.sectionTitle) } class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val cardView: MaterialCardView = view as MaterialCardView val titleView: TextView = view.findViewById(R.id.historyTitle) - val urlView: TextView = view.findViewById(R.id.historyUrl) val timeView: TextView = view.findViewById(R.id.historyTime) val faviconView: ImageView = view.findViewById(R.id.historyFavicon) + val divider: View = view.findViewById(R.id.resultDivider) + var faviconJob: Job? = null + + fun bindFavicon(url: String) { + faviconJob?.cancel() + faviconView.setImageResource(R.drawable.ic_baseline_history) + faviconJob = CoroutineScope(Dispatchers.Main).launch { + val icon = FaviconLoader.loadFavicon(itemView.context, url) + if (icon != null) { + faviconView.setImageBitmap(icon) + faviconView.clearColorFilter() + } + } + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/prirai/android/nira/history/HistorySection.kt b/app/src/main/java/com/prirai/android/nira/history/HistorySection.kt index 0e90dd7..796e17d 100644 --- a/app/src/main/java/com/prirai/android/nira/history/HistorySection.kt +++ b/app/src/main/java/com/prirai/android/nira/history/HistorySection.kt @@ -32,10 +32,9 @@ object HistorySectionHelper { return when { days == 0L -> "Today" days == 1L -> "Yesterday" - days < 7 -> "This Week" - days < 30 -> "This Month" - days < 365 -> "Older" - else -> "Long Time Ago" + days < 7 -> "This week" + days < 30 -> "This month" + else -> "Older" } } } diff --git a/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt b/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt index 59b7d2c..9a444b2 100644 --- a/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt +++ b/app/src/main/java/com/prirai/android/nira/preferences/UserPreferences.kt @@ -68,6 +68,7 @@ class UserPreferences(appContext: Context) : mozilla.components.support.ktx.andr var addonSort by intPreference(ADDON_SORT, AddonSortType.RATING.ordinal) var showUrlProtocol by booleanPreference(SHOW_URL_PROTOCOL, false) var searchSuggestionsEnabled by booleanPreference(SEARCH_SUGGESTIONS, true) + var searchSuggestionCount by intPreference(SEARCH_SUGGESTION_COUNT, 5) var safeBrowsing by booleanPreference(SAFE_BROWSING, true) var trackingProtection: Boolean get() = etpLevel != 0 @@ -172,6 +173,7 @@ class UserPreferences(appContext: Context) : mozilla.components.support.ktx.andr const val PROMPT_EXTERNAL_DOWNLOADER = "prompt_external_downloader" const val SHOW_URL_PROTOCOL = "show_url_protocol" const val SEARCH_SUGGESTIONS = "search_suggestions" + const val SEARCH_SUGGESTION_COUNT = "search_suggestion_count" const val SAFE_BROWSING = "safe_browsing" const val TRACKING_PROTECTION = "tracking_protection" const val SHOW_SHORTCUTS = "show_shortcuts" diff --git a/app/src/main/java/com/prirai/android/nira/search/SearchDialogController.kt b/app/src/main/java/com/prirai/android/nira/search/SearchDialogController.kt index 1c58e4a..22a58c3 100644 --- a/app/src/main/java/com/prirai/android/nira/search/SearchDialogController.kt +++ b/app/src/main/java/com/prirai/android/nira/search/SearchDialogController.kt @@ -3,7 +3,10 @@ package com.prirai.android.nira.search import androidx.navigation.NavController import com.prirai.android.nira.BrowserActivity import com.prirai.android.nira.BrowserDirection +import com.prirai.android.nira.browser.SearchEngineList +import com.prirai.android.nira.preferences.UserPreferences import mozilla.components.browser.state.search.SearchEngine +import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine import mozilla.components.browser.state.store.BrowserStore import mozilla.components.feature.tabs.TabsUseCases @@ -47,9 +50,7 @@ class SearchDialogController( private fun openSearchOrUrl(url: String) { clearToolbarFocus() - val searchEngine = fragmentStore.state.searchEngineSource.searchEngine - - // Determine if we need a new tab based on current tab state + val searchEngine = resolveSearchEngine() val shouldCreateNewTab = shouldCreateNewTabForSearch() activity.openToBrowserAndLoad( @@ -59,16 +60,21 @@ class SearchDialogController( engine = searchEngine ) } - + + private fun resolveSearchEngine(): SearchEngine? { + fragmentStore.state.searchEngineSource.searchEngine?.let { return it } + fragmentStore.state.defaultEngine?.let { return it } + store.state.search.selectedOrDefaultSearchEngine?.let { return it } + return try { + SearchEngineList(activity).getSelectedEngine(UserPreferences(activity)) + } catch (_: Exception) { + null + } + } + /** - * Determines if a new tab should be created for the search. * Creates a new tab ONLY when no tab is selected. * Otherwise, reuses the current tab (including homepage tabs). - * - * This ensures: - * - Searching from home page navigates that tab (replaces homepage) - * - Searching from an existing content tab reuses that tab - * - Only creates new tab when there's no tab at all */ private fun shouldCreateNewTabForSearch(): Boolean { val tabId = fragmentStore.state.tabId @@ -115,7 +121,7 @@ class SearchDialogController( override fun handleSearchTermsTapped(searchTerms: String) { clearToolbarFocus() - val searchEngine = fragmentStore.state.searchEngineSource.searchEngine + val searchEngine = resolveSearchEngine() val shouldCreateNewTab = shouldCreateNewTabForSearch() activity.openToBrowserAndLoad( diff --git a/app/src/main/java/com/prirai/android/nira/search/SearchDialogFragment.kt b/app/src/main/java/com/prirai/android/nira/search/SearchDialogFragment.kt index d375a5a..d631063 100644 --- a/app/src/main/java/com/prirai/android/nira/search/SearchDialogFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/search/SearchDialogFragment.kt @@ -29,8 +29,12 @@ import com.prirai.android.nira.ext.components import com.prirai.android.nira.preferences.UserPreferences import com.prirai.android.nira.search.awesomebar.AwesomeBarView import com.prirai.android.nira.search.toolbar.ToolbarView +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import androidx.lifecycle.lifecycleScope +import mozilla.components.concept.awesomebar.AwesomeBar import mozilla.components.concept.storage.HistoryStorage import mozilla.components.lib.state.ext.consumeFlow import mozilla.components.lib.state.ext.consumeFrom @@ -158,7 +162,8 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { createInitialSearchFragmentState( components, tabId = args.sessionId, - pastedText = args.pastedText + pastedText = args.pastedText, + context = requireContext() ) ) @@ -207,6 +212,17 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { } awesomeBarView.view.setOnEditSuggestionListener(toolbarView.view::setSearchTerms) + awesomeBarView.view.setOnStopListener { + dialogHandledAction = true + dismissAllowingStateLoss() + } + awesomeBarView.view.setOnRemoveSuggestionButtonClicked { grouped -> + val suggestion = grouped.suggestion as? AwesomeBar.Suggestion ?: return@setOnRemoveSuggestionButtonClicked + val url = suggestion.description ?: suggestion.editSuggestion ?: return@setOnRemoveSuggestionButtonClicked + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { + components.historyStorage.deleteVisitsFor(url) + } + } val urlView = toolbarView.view .findViewById(R.id.mozac_browser_toolbar_edit_url_view) @@ -269,6 +285,7 @@ class SearchDialogFragment : AppCompatDialogFragment(), UserInteractionHandler { newTab = store.state.tabId == null, from = BrowserDirection.FromSearchDialog, engine = store.state.searchEngineSource.searchEngine + ?: store.state.defaultEngine ) dismiss() diff --git a/app/src/main/java/com/prirai/android/nira/search/SearchFragmentStore.kt b/app/src/main/java/com/prirai/android/nira/search/SearchFragmentStore.kt index 255105c..c1c5509 100644 --- a/app/src/main/java/com/prirai/android/nira/search/SearchFragmentStore.kt +++ b/app/src/main/java/com/prirai/android/nira/search/SearchFragmentStore.kt @@ -1,6 +1,9 @@ package com.prirai.android.nira.search +import android.content.Context +import com.prirai.android.nira.browser.SearchEngineList import com.prirai.android.nira.components.Components +import com.prirai.android.nira.preferences.UserPreferences import mozilla.components.browser.state.search.SearchEngine import mozilla.components.browser.state.selector.findTab import mozilla.components.browser.state.state.SearchState @@ -77,10 +80,19 @@ data class SearchFragmentState( fun createInitialSearchFragmentState( components: Components, tabId: String?, - pastedText: String? + pastedText: String?, + context: Context? = null ): SearchFragmentState { val tab = tabId?.let { components.store.state.findTab(it) } val url = tab?.content?.url.orEmpty() + val defaultEngine = components.store.state.search.selectedOrDefaultSearchEngine + ?: context?.let { + try { + SearchEngineList(it).getSelectedEngine(UserPreferences(it)) + } catch (_: Exception) { + null + } + } val shouldShowSearchSuggestions = true @@ -88,8 +100,9 @@ fun createInitialSearchFragmentState( query = url, url = url, searchTerms = tab?.content?.searchTerms.orEmpty(), - searchEngineSource = SearchEngineSource.None, - defaultEngine = null, + searchEngineSource = defaultEngine?.let { SearchEngineSource.Default(it) } + ?: SearchEngineSource.None, + defaultEngine = defaultEngine, showSearchSuggestions = shouldShowSearchSuggestions, showSearchSuggestionsHint = false, showSearchShortcuts = false, @@ -139,18 +152,15 @@ private fun searchStateReducer(state: SearchFragmentState, action: SearchFragmen is SearchFragmentAction.SetShowSearchSuggestions -> state.copy(showSearchSuggestions = action.show) is SearchFragmentAction.UpdateSearchState -> { + val resolvedEngine = action.search.selectedOrDefaultSearchEngine ?: state.defaultEngine state.copy( - defaultEngine = action.search.selectedOrDefaultSearchEngine, + defaultEngine = resolvedEngine, areShortcutsAvailable = action.search.searchEngines.size > 1, showSearchShortcuts = state.url.isEmpty() && state.showSearchShortcutsSetting && action.search.searchEngines.size > 1, searchEngineSource = (state.searchEngineSource as? SearchEngineSource.Shortcut) - ?: (action.search.selectedOrDefaultSearchEngine?.let { - SearchEngineSource.Default( - it - ) - } + ?: (resolvedEngine?.let { SearchEngineSource.Default(it) } ?: SearchEngineSource.None) ) } diff --git a/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarView.kt b/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarView.kt index 1807357..ceb7aa6 100644 --- a/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarView.kt +++ b/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarView.kt @@ -8,17 +8,17 @@ import androidx.core.graphics.drawable.toBitmap import com.prirai.android.nira.BrowserActivity import com.prirai.android.nira.R import com.prirai.android.nira.browser.BrowsingMode +import com.prirai.android.nira.browser.SearchEngineList import com.prirai.android.nira.browser.bookmark.CustomBookmarksStorage import com.prirai.android.nira.ext.components import com.prirai.android.nira.preferences.UserPreferences import com.prirai.android.nira.search.SearchEngineSource import com.prirai.android.nira.search.SearchFragmentState import mozilla.components.browser.state.search.SearchEngine +import mozilla.components.browser.state.state.selectedOrDefaultSearchEngine import mozilla.components.concept.awesomebar.AwesomeBar import mozilla.components.concept.engine.EngineSession import mozilla.components.feature.awesomebar.provider.BookmarksStorageSuggestionProvider -import mozilla.components.feature.awesomebar.provider.HistoryStorageSuggestionProvider -import mozilla.components.feature.awesomebar.provider.SearchActionProvider import mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider import mozilla.components.feature.search.SearchUseCases import mozilla.components.feature.session.SessionUseCases @@ -35,11 +35,11 @@ class AwesomeBarView( val view: AwesomeBarWrapper, ) { private val sessionProvider: NiraTabSuggestionProvider - private val historyStorageProvider: HistoryStorageSuggestionProvider + private val historyStorageProvider: NiraHistorySuggestionProvider private val bookmarksStorageSuggestionProvider: BookmarksStorageSuggestionProvider private val shortcutsEnginePickerProvider: ShortcutsSuggestionProvider private val defaultSearchSuggestionProvider: SearchSuggestionProvider - private val defaultSearchActionProvider: SearchActionProvider + private val defaultSearchActionProvider: SearchForQueryProvider private val searchSuggestionProviderMap: MutableMap> private var providersInUse = mutableSetOf() @@ -94,15 +94,17 @@ class AwesomeBarView( store = components.store, selectTabUseCase = selectTabUseCase, removeTabUseCase = components.tabsUseCases.removeTab, - switchToTabDescription = activity.resources.getString(R.string.switch_to_tab) + switchToTabDescription = activity.resources.getString(R.string.switch_to_tab), + suggestionsHeader = activity.getString(R.string.tabs) ) historyStorageProvider = - HistoryStorageSuggestionProvider( - components.historyStorage, - loadUrlUseCase, - components.icons, - engineForSpeculativeConnects + NiraHistorySuggestionProvider( + historyStorage = components.historyStorage, + loadUrlUseCase = loadUrlUseCase, + icons = components.icons, + engine = engineForSpeculativeConnects, + suggestionsHeader = activity.getString(R.string.action_history) ) bookmarksStorageSuggestionProvider = @@ -110,18 +112,25 @@ class AwesomeBarView( bookmarksStorage = CustomBookmarksStorage(activity), loadUrlUseCase = loadUrlUseCase, icons = components.icons, - engine = engineForSpeculativeConnects + engine = engineForSpeculativeConnects, + showEditSuggestion = false, + suggestionsHeader = activity.getString(R.string.action_bookmarks) ) val searchBitmap = getDrawable(activity, R.drawable.ic_ios_search)!!.apply { colorFilter = createBlendModeColorFilterCompat(primaryTextColor, SRC_IN) }.toBitmap() + val selectedEngine = components.store.state.search.selectedOrDefaultSearchEngine + ?: SearchEngineList(activity).getSelectedEngine(UserPreferences(activity)) + val suggestionLimit = UserPreferences(activity).searchSuggestionCount.coerceIn(1, 10) + defaultSearchSuggestionProvider = SearchSuggestionProvider( - store = components.store, + searchEngine = selectedEngine, searchUseCase = searchUseCase, fetchClient = components.client, + limit = suggestionLimit, mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS, icon = searchBitmap, showDescription = false, @@ -134,11 +143,10 @@ class AwesomeBarView( ) defaultSearchActionProvider = - SearchActionProvider( - store = components.store, + SearchForQueryProvider( searchUseCase = searchUseCase, icon = searchBitmap, - showDescription = false + titleFor = { query -> activity.getString(R.string.search_for_query, query) } ) shortcutsEnginePickerProvider = @@ -178,16 +186,17 @@ class AwesomeBarView( providersToRemove: MutableSet ) { for (provider in providersToAdd) { - if (providersInUse.find { it.id == provider.id } == null) { + if (providersInUse.none { it.id == provider.id }) { providersInUse.add(provider) view.addProviders(provider) } } for (provider in providersToRemove) { - if (providersInUse.find { it.id == provider.id } != null) { - providersInUse.remove(provider) - view.removeProviders(provider) + val existing = providersInUse.filter { it.id == provider.id } + if (existing.isNotEmpty()) { + providersInUse.removeAll(existing.toSet()) + view.removeProviders(*existing.toTypedArray()) } } } @@ -204,7 +213,9 @@ class AwesomeBarView( } if (state.showSearchSuggestions) { - providersToAdd.addAll(getSelectedSearchSuggestionProvider(context, state)) + getSelectedSearchSuggestionProvider(context, state).forEach { provider -> + providersToAdd.add(provider) + } } if (!activity.browsingModeManager.mode.isPrivate) { @@ -238,14 +249,27 @@ class AwesomeBarView( //TODO: Clean this up when switching to search suggestion provider option return when (state.searchEngineSource) { is SearchEngineSource.Default -> { - if(UserPreferences(context).searchSuggestionsEnabled){ + if (UserPreferences(context).searchSuggestionsEnabled) { listOf( - defaultSearchActionProvider, - defaultSearchSuggestionProvider + HeaderedSuggestionProvider( + defaultSearchActionProvider, + header = "", + priority = 50 + ), + HeaderedSuggestionProvider( + defaultSearchSuggestionProvider, + header = context.getString(R.string.search_suggestions), + priority = 40 + ) + ) + } else { + listOf( + HeaderedSuggestionProvider( + defaultSearchActionProvider, + header = "", + priority = 50 + ) ) - } - else{ - emptyList() } } is SearchEngineSource.Shortcut -> getSuggestionProviderForEngine( @@ -277,25 +301,32 @@ class AwesomeBarView( } listOf( - SearchActionProvider( - searchEngine = engine, - store = components.store, - searchUseCase = shortcutSearchUseCase, - icon = searchBitmap + HeaderedSuggestionProvider( + SearchForQueryProvider( + searchUseCase = shortcutSearchUseCase, + icon = searchBitmap, + titleFor = { query -> activity.getString(R.string.search_for_query, query) } + ), + header = "", + priority = 50 ), - SearchSuggestionProvider( - store = components.store, - searchUseCase = shortcutSearchUseCase, - fetchClient = components.client, - limit = 3, - mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS, - icon = searchBitmap, - engine = engineForSpeculativeConnects, - filterExactMatch = true, - private = when (activity.browsingModeManager.mode) { - BrowsingMode.Normal -> false - BrowsingMode.Private -> true - } + HeaderedSuggestionProvider( + SearchSuggestionProvider( + searchEngine = engine, + searchUseCase = shortcutSearchUseCase, + fetchClient = components.client, + limit = UserPreferences(activity).searchSuggestionCount.coerceIn(1, 10), + mode = SearchSuggestionProvider.Mode.MULTIPLE_SUGGESTIONS, + icon = searchBitmap, + engine = engineForSpeculativeConnects, + filterExactMatch = true, + private = when (activity.browsingModeManager.mode) { + BrowsingMode.Normal -> false + BrowsingMode.Private -> true + } + ), + header = activity.getString(R.string.search_suggestions), + priority = 40 ) ) } diff --git a/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarWrapper.kt b/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarWrapper.kt index d29f4f7..0b6d838 100644 --- a/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarWrapper.kt +++ b/app/src/main/java/com/prirai/android/nira/search/awesomebar/AwesomeBarWrapper.kt @@ -4,19 +4,12 @@ import android.content.Context import android.util.AttributeSet import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.AbstractComposeView -import com.prirai.android.nira.components.toolbar.ToolbarPosition import com.prirai.android.nira.preferences.UserPreferences -import com.prirai.android.nira.theme.FirefoxTheme -import mozilla.components.compose.browser.awesomebar.AwesomeBar -import mozilla.components.compose.browser.awesomebar.AwesomeBarDefaults -import mozilla.components.compose.browser.awesomebar.AwesomeBarOrientation +import com.prirai.android.nira.ui.theme.NiraTheme import mozilla.components.concept.awesomebar.AwesomeBar import mozilla.components.concept.awesomebar.AwesomeBar.GroupedSuggestion -import mozilla.components.concept.awesomebar.AwesomeBar.Suggestion -import mozilla.components.concept.awesomebar.AwesomeBar.SuggestionItem -import mozilla.components.support.ktx.android.content.getColorFromAttr +import mozilla.components.support.ktx.android.view.hideKeyboard class AwesomeBarWrapper @JvmOverloads constructor( context: Context, @@ -25,6 +18,7 @@ class AwesomeBarWrapper @JvmOverloads constructor( ) : AbstractComposeView(context, attrs, defStyleAttr), AwesomeBar { private val providers = mutableStateOf(emptyList()) private val text = mutableStateOf("") + private val hiddenSuggestions = mutableStateOf(emptySet()) private var onEditSuggestionListener: ((String) -> Unit)? = null private var onStopListener: (() -> Unit)? = null private var onRemoveSuggestionButtonClicked: ((GroupedSuggestion) -> Unit)? = null @@ -35,25 +29,24 @@ class AwesomeBarWrapper @JvmOverloads constructor( return } - if (UserPreferences(context).toolbarPosition == ToolbarPosition.TOP.ordinal) { - AwesomeBarOrientation.BOTTOM - } else { - AwesomeBarOrientation.TOP - } - - FirefoxTheme { - AwesomeBar( + val prefs = UserPreferences(context) + NiraTheme( + amoledMode = prefs.amoledMode, + dynamicColor = prefs.dynamicColors + ) { + NiraAwesomeBar( text = text.value, providers = providers.value, - colors = AwesomeBarDefaults.colors( - background = Color.Transparent, - title = Color(context.getColorFromAttr(android.R.attr.textColorPrimary)), - description = Color(context.getColorFromAttr(android.R.attr.textColorSecondary)), - autocompleteIcon = Color(context.getColorFromAttr(android.R.attr.textColorSecondary)) - ), - onSuggestionClicked = { }, - onRemoveClicked = { }, - onAutoComplete = { } + hiddenSuggestions = hiddenSuggestions.value, + onSuggestionClicked = { suggestion -> + suggestion.onSuggestionClicked?.invoke() + onStopListener?.invoke() + }, + onRemoveClicked = { grouped -> + hiddenSuggestions.value += grouped + onRemoveSuggestionButtonClicked?.invoke(grouped) + }, + onScroll = { hideKeyboard() } ) } } @@ -69,6 +62,7 @@ class AwesomeBarWrapper @JvmOverloads constructor( } override fun onInputChanged(text: String) { + hiddenSuggestions.value = emptySet() this.text.value = text } @@ -91,7 +85,7 @@ class AwesomeBarWrapper @JvmOverloads constructor( } override fun updateHiddenSuggestions(hiddenSuggestions: Set) { - // No-op: compose awesomebar handles its own hidden state. + this.hiddenSuggestions.value = hiddenSuggestions } override fun setOnRemoveSuggestionButtonClicked(listener: (GroupedSuggestion) -> Unit) { diff --git a/app/src/main/java/com/prirai/android/nira/search/awesomebar/NiraAwesomeBar.kt b/app/src/main/java/com/prirai/android/nira/search/awesomebar/NiraAwesomeBar.kt new file mode 100644 index 0000000..620a837 --- /dev/null +++ b/app/src/main/java/com/prirai/android/nira/search/awesomebar/NiraAwesomeBar.kt @@ -0,0 +1,249 @@ +package com.prirai.android.nira.search.awesomebar + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.prirai.android.nira.R +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import mozilla.components.concept.awesomebar.AwesomeBar +import mozilla.components.concept.awesomebar.AwesomeBar.GroupedSuggestion +import mozilla.components.concept.awesomebar.AwesomeBar.Suggestion +import mozilla.components.concept.awesomebar.AwesomeBar.SuggestionProvider +import mozilla.components.concept.awesomebar.AwesomeBar.SuggestionProviderGroup + +private val GroupCornerRadius = 24.dp +private val CardHorizontalPadding = 8.dp + +private data class SuggestionSection( + val group: SuggestionProviderGroup, + val suggestions: List, +) + +@Composable +fun NiraAwesomeBar( + text: String, + providers: List, + hiddenSuggestions: Set, + onSuggestionClicked: (Suggestion) -> Unit, + onRemoveClicked: (GroupedSuggestion) -> Unit, + onScroll: () -> Unit, +) { + var sections by remember { mutableStateOf(emptyList()) } + + LaunchedEffect(text, providers) { + if (providers.isEmpty() || text.isBlank()) { + sections = emptyList() + return@LaunchedEffect + } + sections = fetchSections(text, providers) + } + + val visibleSections = remember(sections, hiddenSuggestions) { + sections.mapNotNull { section -> + val visible = section.suggestions.filterNot { suggestion -> + GroupedSuggestion(suggestion, section.group.id) in hiddenSuggestions + } + if (visible.isEmpty()) null else section.copy(suggestions = visible) + } + } + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + userScrollEnabled = true + ) { + visibleSections.forEach { section -> + if (!section.group.title.isNullOrBlank()) { + item(key = "header-${section.group.id}") { + Text( + text = section.group.title.orEmpty(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp) + ) + } + } + itemsIndexed( + items = section.suggestions, + key = { _, suggestion -> "${section.group.id}-${suggestion.provider.id}-${suggestion.id}" } + ) { index, suggestion -> + SuggestionCardRow( + suggestion = suggestion, + isFirst = index == 0, + isLast = index == section.suggestions.lastIndex, + onClick = { onSuggestionClicked(suggestion) }, + onRemove = { + onRemoveClicked(GroupedSuggestion(suggestion, section.group.id)) + } + ) + } + } + } +} + +@Composable +private fun SuggestionCardRow( + suggestion: Suggestion, + isFirst: Boolean, + isLast: Boolean, + onClick: () -> Unit, + onRemove: () -> Unit, +) { + val shape = RoundedCornerShape( + topStart = if (isFirst) GroupCornerRadius else 0.dp, + topEnd = if (isFirst) GroupCornerRadius else 0.dp, + bottomStart = if (isLast) GroupCornerRadius else 0.dp, + bottomEnd = if (isLast) GroupCornerRadius else 0.dp, + ) + + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CardHorizontalPadding) + .clip(shape) + .clickable(onClick = onClick), + shape = shape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 1.dp + ) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val icon = suggestion.icon + if (icon != null) { + Image( + bitmap = icon.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(4.dp)), + contentScale = ContentScale.Crop + ) + } else { + Spacer(modifier = Modifier.size(24.dp)) + } + + Text( + text = suggestion.title?.ifBlank { null } ?: suggestion.description.orEmpty(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(start = 12.dp, end = 8.dp) + ) + + if (suggestion.isRemovalAllowed) { + IconButton( + onClick = onRemove, + modifier = Modifier.size(32.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_close_small), + contentDescription = stringResource(R.string.remove_history_item), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + } + + if (!isLast) { + HorizontalDivider( + modifier = Modifier.padding(start = 48.dp), + color = MaterialTheme.colorScheme.outlineVariant + ) + } + } + } +} + +private suspend fun fetchSections( + text: String, + providers: List, +): List = coroutineScope { + val groups = providers + .groupBy { it.groupTitle() ?: it.id } + .map { (title, groupedProviders) -> + SuggestionProviderGroup( + providers = groupedProviders, + title = groupedProviders.first().groupTitle() ?: title, + priority = groupedProviders.maxOf { providerPriority(it) }, + ) + } + .sortedByDescending { it.priority } + + groups.map { group -> + async { + val suggestions = group.providers + .map { provider -> + async { + provider.onInputChanged(text).filterIsInstance() + } + } + .awaitAll() + .flatten() + .sortedByDescending { it.score } + SuggestionSection(group, suggestions) + } + }.awaitAll().filter { it.suggestions.isNotEmpty() } +} + +internal class HeaderedSuggestionProvider( + private val delegate: SuggestionProvider, + private val header: String, + val priority: Int, +) : SuggestionProvider by delegate { + override fun groupTitle(): String = header +} + +internal fun providerPriority(provider: SuggestionProvider): Int { + return when (provider) { + is HeaderedSuggestionProvider -> provider.priority + is SearchForQueryProvider -> 50 + is mozilla.components.feature.awesomebar.provider.SearchSuggestionProvider -> 40 + is NiraTabSuggestionProvider -> 30 + is NiraHistorySuggestionProvider -> 20 + is mozilla.components.feature.awesomebar.provider.BookmarksStorageSuggestionProvider -> 10 + else -> 0 + } +} diff --git a/app/src/main/java/com/prirai/android/nira/search/awesomebar/NiraHistorySuggestionProvider.kt b/app/src/main/java/com/prirai/android/nira/search/awesomebar/NiraHistorySuggestionProvider.kt new file mode 100644 index 0000000..36c34b0 --- /dev/null +++ b/app/src/main/java/com/prirai/android/nira/search/awesomebar/NiraHistorySuggestionProvider.kt @@ -0,0 +1,54 @@ +package com.prirai.android.nira.search.awesomebar + +import java.util.UUID +import mozilla.components.browser.icons.BrowserIcons +import mozilla.components.browser.icons.IconRequest +import mozilla.components.concept.awesomebar.AwesomeBar +import mozilla.components.concept.engine.Engine +import mozilla.components.concept.storage.HistoryStorage +import mozilla.components.feature.session.SessionUseCases + +class NiraHistorySuggestionProvider( + private val historyStorage: HistoryStorage, + private val loadUrlUseCase: SessionUseCases.LoadUrlUseCase, + private val icons: BrowserIcons? = null, + private val engine: Engine? = null, + private val maxNumberOfSuggestions: Int = 20, + private val suggestionsHeader: String? = null, +) : AwesomeBar.SuggestionProvider { + + override val id: String = UUID.randomUUID().toString() + + override fun groupTitle(): String? = suggestionsHeader + + override suspend fun onInputChanged(text: String): List { + if (text.isEmpty()) { + return emptyList() + } + + historyStorage.cancelReads(text) + val results = historyStorage + .getSuggestions(text, maxNumberOfSuggestions) + .sortedByDescending { it.score } + .distinctBy { it.id } + .take(maxNumberOfSuggestions) + + results.firstOrNull()?.url?.let { engine?.speculativeConnect(it) } + + val iconRequests = results.map { icons?.loadIcon(IconRequest(url = it.url, waitOnNetworkLoad = false)) } + return results.zip(iconRequests) { result, icon -> + AwesomeBar.Suggestion( + provider = this, + id = result.id, + icon = icon?.await()?.bitmap, + title = result.title?.ifBlank { null } ?: result.url, + description = result.url, + editSuggestion = null, + isRemovalAllowed = true, + score = result.score, + flags = setOf(AwesomeBar.Suggestion.Flag.HISTORY), + onSuggestionClicked = { loadUrlUseCase(result.url) }, + ) + } + } +} diff --git a/app/src/main/java/com/prirai/android/nira/search/awesomebar/NiraTabSuggestionProvider.kt b/app/src/main/java/com/prirai/android/nira/search/awesomebar/NiraTabSuggestionProvider.kt index 72a5627..db09528 100644 --- a/app/src/main/java/com/prirai/android/nira/search/awesomebar/NiraTabSuggestionProvider.kt +++ b/app/src/main/java/com/prirai/android/nira/search/awesomebar/NiraTabSuggestionProvider.kt @@ -1,10 +1,10 @@ package com.prirai.android.nira.search.awesomebar import android.content.Context +import com.prirai.android.nira.browser.tabgroups.UnifiedTabGroupManager import mozilla.components.browser.state.store.BrowserStore import mozilla.components.concept.awesomebar.AwesomeBar import mozilla.components.feature.tabs.TabsUseCases -import com.prirai.android.nira.browser.tabgroups.UnifiedTabGroupManager import java.util.UUID class NiraTabSuggestionProvider( @@ -13,10 +13,13 @@ class NiraTabSuggestionProvider( private val selectTabUseCase: TabsUseCases.SelectTabUseCase, private val removeTabUseCase: TabsUseCases.RemoveTabUseCase, private val switchToTabDescription: String = "Switch to tab", + private val suggestionsHeader: String? = null, ) : AwesomeBar.SuggestionProvider { override val id: String = UUID.randomUUID().toString() + override fun groupTitle(): String? = suggestionsHeader + private val groupManager by lazy { UnifiedTabGroupManager.getInstance(context) } override suspend fun onInputChanged(text: String): List { @@ -26,9 +29,9 @@ class NiraTabSuggestionProvider( return state.tabs .filter { tab -> tab.id != selectedTabId && !tab.content.private && - (text.isBlank() || - tab.content.title.contains(text, ignoreCase = true) || - tab.content.url.contains(text, ignoreCase = true)) + (text.isBlank() || + tab.content.title.contains(text, ignoreCase = true) || + tab.content.url.contains(text, ignoreCase = true)) } .mapIndexed { index, tab -> val group = groupManager.getGroupForTab(tab.id) @@ -40,7 +43,7 @@ class NiraTabSuggestionProvider( provider = this, id = tab.id, title = tab.content.title.ifBlank { tab.content.url }, - description = switchToTabDescription, + description = null, icon = null, chips = chips, score = Int.MAX_VALUE - index, diff --git a/app/src/main/java/com/prirai/android/nira/search/awesomebar/SearchForQueryProvider.kt b/app/src/main/java/com/prirai/android/nira/search/awesomebar/SearchForQueryProvider.kt new file mode 100644 index 0000000..64536db --- /dev/null +++ b/app/src/main/java/com/prirai/android/nira/search/awesomebar/SearchForQueryProvider.kt @@ -0,0 +1,39 @@ +package com.prirai.android.nira.search.awesomebar + +import android.graphics.Bitmap +import java.util.UUID +import mozilla.components.concept.awesomebar.AwesomeBar +import mozilla.components.feature.search.SearchUseCases + +class SearchForQueryProvider( + private val searchUseCase: SearchUseCases.SearchUseCase, + private val icon: Bitmap?, + private val titleFor: (String) -> String, + private val suggestionsHeader: String? = null, +) : AwesomeBar.SuggestionProvider { + + override val id: String = UUID.randomUUID().toString() + + override fun groupTitle(): String? = suggestionsHeader + + override suspend fun onInputChanged(text: String): List { + if (text.isBlank()) { + return emptyList() + } + + return listOf( + AwesomeBar.Suggestion( + provider = this, + id = FIXED_ID, + title = titleFor(text), + icon = icon, + score = Int.MAX_VALUE, + onSuggestionClicked = { searchUseCase.invoke(text) }, + ) + ) + } + + companion object { + private const val FIXED_ID = "nira.search.for.query" + } +} diff --git a/app/src/main/java/com/prirai/android/nira/search/awesomebar/ShortcutsSuggestionProvider.kt b/app/src/main/java/com/prirai/android/nira/search/awesomebar/ShortcutsSuggestionProvider.kt index 754a18c..817ca0d 100644 --- a/app/src/main/java/com/prirai/android/nira/search/awesomebar/ShortcutsSuggestionProvider.kt +++ b/app/src/main/java/com/prirai/android/nira/search/awesomebar/ShortcutsSuggestionProvider.kt @@ -14,6 +14,8 @@ class ShortcutsSuggestionProvider( ) : AwesomeBar.SuggestionProvider { override val id: String = UUID.randomUUID().toString() + override fun groupTitle(): String? = context.getString(com.prirai.android.nira.R.string.change_search_engine) + override suspend fun onInputChanged(text: String): List { val suggestions = mutableListOf() diff --git a/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt b/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt index 1adf865..5713e63 100644 --- a/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt +++ b/app/src/main/java/com/prirai/android/nira/settings/fragment/GeneralSettingsFragment.kt @@ -40,6 +40,14 @@ class GeneralSettingsFragment : BaseSettingsFragment() { } ) + seekbarPreference( + preference = requireContext().resources.getString(R.string.key_search_suggestion_count) + ) { + UserPreferences(requireContext()).searchSuggestionCount = it.coerceIn(1, 10) + }?.apply { + value = UserPreferences(requireContext()).searchSuggestionCount.coerceIn(1, 10) + } + switchPreference( preference = requireContext().resources.getString(R.string.key_safe_browsing), isChecked = UserPreferences(requireContext()).safeBrowsing, diff --git a/app/src/main/java/com/prirai/android/nira/ui/theme/Theme.kt b/app/src/main/java/com/prirai/android/nira/ui/theme/Theme.kt index 2156672..fd97340 100644 --- a/app/src/main/java/com/prirai/android/nira/ui/theme/Theme.kt +++ b/app/src/main/java/com/prirai/android/nira/ui/theme/Theme.kt @@ -1,6 +1,8 @@ package com.prirai.android.nira.ui.theme import android.app.Activity +import android.content.Context +import android.content.ContextWrapper import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -128,7 +130,7 @@ fun NiraTheme( val view = LocalView.current if (!view.isInEditMode) { SideEffect { - val window = (view.context as Activity).window + val window = view.context.findActivity()?.window ?: return@SideEffect // Set status bar and navigation bar colors if (isPrivateMode) { // Purple for private mode @@ -154,3 +156,12 @@ fun NiraTheme( content = content ) } + +private fun Context.findActivity(): Activity? { + var current: Context? = this + while (current is ContextWrapper) { + if (current is Activity) return current + current = current.baseContext + } + return current as? Activity +} diff --git a/app/src/main/res/layout/history_list_item.xml b/app/src/main/res/layout/history_list_item.xml index f3719e7..bc08af0 100644 --- a/app/src/main/res/layout/history_list_item.xml +++ b/app/src/main/res/layout/history_list_item.xml @@ -4,85 +4,65 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginStart="16dp" - android:layout_marginEnd="16dp" - android:layout_marginTop="4dp" - android:layout_marginBottom="4dp" + android:layout_marginHorizontal="8dp" + style="@style/Widget.Material3.CardView.Elevated" + app:cardBackgroundColor="?attr/colorSurfaceContainerHigh" app:cardElevation="1dp" app:cardCornerRadius="12dp" - app:strokeWidth="0dp" - app:cardBackgroundColor="?attr/colorSurface" android:foreground="?attr/selectableItemBackground" android:clickable="true" android:focusable="true"> - + android:orientation="vertical"> - - - + android:orientation="horizontal" + android:paddingHorizontal="12dp" + android:paddingVertical="10dp" + android:gravity="center_vertical"> - + - + + + + - + + diff --git a/app/src/main/res/layout/history_section_header.xml b/app/src/main/res/layout/history_section_header.xml index 74ec5b2..4d101db 100644 --- a/app/src/main/res/layout/history_section_header.xml +++ b/app/src/main/res/layout/history_section_header.xml @@ -1,23 +1,12 @@ - - - - - + android:paddingBottom="8dp" + android:textAppearance="@style/TextAppearance.Material3.TitleMedium" + android:textColor="?attr/colorOnSurface" + tools:text="Today" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 309615c..6d23107 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -95,10 +95,14 @@ name="key_show_protocol" translatable="false" >show_url_protocol - search_suggestions_enabled + search_suggestion_count safe_browsing Show URL protocol in toolbar Search suggestions + Search for \"%1$s\" + Number of search suggestions Safe browsing Tracking protection diff --git a/app/src/main/res/xml/preferences_general.xml b/app/src/main/res/xml/preferences_general.xml index 9acd490..b49da6e 100644 --- a/app/src/main/res/xml/preferences_general.xml +++ b/app/src/main/res/xml/preferences_general.xml @@ -18,6 +18,16 @@ app:singleLineTitle="false" app:defaultValue="true"/> + +