WIP Search

This commit is contained in:
Philipp Heckel 2026-01-24 16:19:08 -05:00
parent 095a9f5be3
commit c2396dd1a3
5 changed files with 139 additions and 11 deletions

View file

@ -9,12 +9,15 @@ import android.text.Html
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.EditText
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
@ -92,6 +95,10 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
private lateinit var messageBarPublishButton: FloatingActionButton
private lateinit var messageBarExpandButton: ImageButton
// Search state
private var searchView: SearchView? = null
private var isSearchActive: Boolean = false
// Action mode stuff
private var actionMode: ActionMode? = null
private val actionModeCallback = object : ActionMode.Callback {
@ -307,20 +314,39 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
insets
}
viewModel.list(subscriptionId).observe(this) {
it?.let {
// Observe filtered notifications (filtered by search query)
val noSearchResultsText: TextView = findViewById(R.id.detail_no_notifications_text)
val howToIntro: View = findViewById(R.id.detail_how_to_intro)
val howToExampleView: View = findViewById(R.id.detail_how_to_example)
val howToLinkView: View = findViewById(R.id.detail_how_to_link)
viewModel.listFiltered(subscriptionId).observe(this) {
it?.let { notifications ->
// Show list view
adapter.submitList(it as MutableList<Notification>)
if (it.isEmpty()) {
adapter.submitList(notifications.toMutableList())
if (notifications.isEmpty()) {
mainListContainer.visibility = View.GONE
noEntriesText.visibility = View.VISIBLE
// Show different text based on whether we're searching or not
if (isSearchActive && viewModel.searchQuery.value?.isNotEmpty() == true) {
noSearchResultsText.text = getString(R.string.detail_no_search_results)
// Hide "how to" instructions when showing search results
howToIntro.visibility = View.GONE
howToExampleView.visibility = View.GONE
howToLinkView.visibility = View.GONE
} else {
noSearchResultsText.text = getString(R.string.detail_no_notifications_text)
// Show "how to" instructions for empty subscription
howToIntro.visibility = View.VISIBLE
howToExampleView.visibility = View.VISIBLE
howToLinkView.visibility = if (BuildConfig.PAYMENT_LINKS_AVAILABLE) View.VISIBLE else View.GONE
}
} else {
mainListContainer.visibility = View.VISIBLE
noEntriesText.visibility = View.GONE
}
// Cancel notifications that still have popups
maybeCancelNotificationPopups(it)
maybeCancelNotificationPopups(notifications)
}
}
@ -553,6 +579,57 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
menu[i].icon?.setTint(toolbarTextColor)
}
// Setup SearchView
val searchItem = menu.findItem(R.id.detail_menu_search)
searchView = searchItem?.actionView as? SearchView
searchView?.let { sv ->
sv.queryHint = getString(R.string.detail_search_hint)
// Tint SearchView icons and text
val searchIcon = sv.findViewById<ImageView>(androidx.appcompat.R.id.search_button)
searchIcon?.setColorFilter(toolbarTextColor)
val closeIcon = sv.findViewById<ImageView>(androidx.appcompat.R.id.search_close_btn)
closeIcon?.setColorFilter(toolbarTextColor)
val searchEditText = sv.findViewById<EditText>(androidx.appcompat.R.id.search_src_text)
searchEditText?.setTextColor(toolbarTextColor)
searchEditText?.setHintTextColor(toolbarTextColor.and(0x80FFFFFF.toInt())) // 50% alpha
// Handle query text changes
sv.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.setSearchQuery(newText ?: "")
return true
}
})
// Handle expand/collapse
searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
isSearchActive = true
// Hide other menu items when search is expanded
setMenuItemsVisibility(false)
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
isSearchActive = false
// Clear search query when collapsed
viewModel.setSearchQuery("")
// Restore menu items visibility
setMenuItemsVisibility(true)
showHideInstantMenuItems(subscriptionInstant)
showHideMutedUntilMenuItems(subscriptionMutedUntil)
showHideCopyMenuItems(subscriptionBaseUrl)
showHideConnectionErrorMenuItem(repository.getConnectionDetails())
return true
}
})
}
// Show and hide buttons
showHideInstantMenuItems(subscriptionInstant)
showHideMutedUntilMenuItems(subscriptionMutedUntil)
@ -566,6 +643,18 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
return true
}
private fun setMenuItemsVisibility(visible: Boolean) {
if (!this::menu.isInitialized) return
// Hide/show all menu items except search
for (i in 0 until menu.size) {
val item = menu[i]
if (item.itemId != R.id.detail_menu_search) {
item.isVisible = visible
}
}
}
private fun startNotificationMutedChecker() {
// FIXME This is awful and has to go.

View file

@ -1,19 +1,44 @@
package io.heckel.ntfy.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import io.heckel.ntfy.db.Notification
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.db.combineWith
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class DetailViewModel(private val repository: Repository) : ViewModel() {
private val _searchQuery = MutableLiveData("")
val searchQuery: LiveData<String> = _searchQuery
fun setSearchQuery(query: String) {
_searchQuery.value = query
}
fun list(subscriptionId: Long): LiveData<List<Notification>> {
return repository.getNotificationsLiveData(subscriptionId)
}
fun listFiltered(subscriptionId: Long): LiveData<List<Notification>> {
return repository.getNotificationsLiveData(subscriptionId)
.combineWith(_searchQuery) { notifications, query ->
if (query.isNullOrBlank()) {
notifications.orEmpty()
} else {
val q = query.lowercase()
notifications.orEmpty().filter { n ->
n.title.lowercase().contains(q) ||
n.message.lowercase().contains(q) ||
n.tags.lowercase().contains(q)
}
}
}
}
fun markAsDeleted(notificationId: String) = viewModelScope.launch(Dispatchers.IO) {
repository.markAsDeleted(notificationId)
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp">
<path
android:fillColor="#FFFFFF"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
</vector>

View file

@ -1,6 +1,12 @@
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/detail_menu_search"
android:icon="@drawable/ic_search_white_24dp"
android:title="@string/detail_menu_search"
app:showAsAction="always|collapseActionView"
app:actionViewClass="androidx.appcompat.widget.SearchView" />
<item
android:id="@+id/detail_menu_connection_error"
android:icon="@drawable/ic_warning_white_24dp"
@ -24,14 +30,10 @@
app:showAsAction="ifRoom" />
<item
android:id="@+id/detail_menu_enable_instant"
android:icon="@drawable/ic_bolt_outline_white_24dp"
android:title="@string/detail_menu_enable_instant"
app:showAsAction="ifRoom" />
android:title="@string/detail_menu_enable_instant" />
<item
android:id="@+id/detail_menu_disable_instant"
android:icon="@drawable/ic_bolt_white_24dp"
android:title="@string/detail_menu_disable_instant"
app:showAsAction="ifRoom" />
android:title="@string/detail_menu_disable_instant" />
<item
android:id="@+id/detail_menu_settings"
android:title="@string/detail_menu_settings" />

View file

@ -203,6 +203,9 @@
<string name="detail_menu_clear">Clear all notifications</string>
<string name="detail_menu_settings">Subscription settings</string>
<string name="detail_menu_unsubscribe">Unsubscribe</string>
<string name="detail_menu_search">Search notifications</string>
<string name="detail_search_hint">Search in notifications</string>
<string name="detail_no_search_results">No notifications match your search</string>
<!-- Detail activity: Action mode -->
<string name="detail_action_mode_menu_delete">Delete</string>