diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index b5c93cf9..0b55d3a9 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -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) - 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(androidx.appcompat.R.id.search_button) + searchIcon?.setColorFilter(toolbarTextColor) + val closeIcon = sv.findViewById(androidx.appcompat.R.id.search_close_btn) + closeIcon?.setColorFilter(toolbarTextColor) + val searchEditText = sv.findViewById(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. diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt index 8606a64a..d8aa793b 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt @@ -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 = _searchQuery + + fun setSearchQuery(query: String) { + _searchQuery.value = query + } + fun list(subscriptionId: Long): LiveData> { return repository.getNotificationsLiveData(subscriptionId) } + fun listFiltered(subscriptionId: Long): LiveData> { + 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) } diff --git a/app/src/main/res/drawable/ic_search_white_24dp.xml b/app/src/main/res/drawable/ic_search_white_24dp.xml new file mode 100644 index 00000000..29a15776 --- /dev/null +++ b/app/src/main/res/drawable/ic_search_white_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/menu/menu_detail_action_bar.xml b/app/src/main/res/menu/menu_detail_action_bar.xml index ff99af16..7f18561e 100644 --- a/app/src/main/res/menu/menu_detail_action_bar.xml +++ b/app/src/main/res/menu/menu_detail_action_bar.xml @@ -1,6 +1,12 @@ + + android:title="@string/detail_menu_enable_instant" /> + android:title="@string/detail_menu_disable_instant" /> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0670b6f1..1ccf3b4d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -203,6 +203,9 @@ Clear all notifications Subscription settings Unsubscribe + Search notifications + Search in notifications + No notifications match your search Delete