Skip to content

Commit e9172bd

Browse files
committed
Highlight keyword in notes search and jump to keyword in note
1 parent 396e7a7 commit e9172bd

File tree

11 files changed

+268
-50
lines changed

11 files changed

+268
-50
lines changed

app/src/main/java/com/philkes/notallyx/presentation/activity/main/fragment/NotallyFragment.kt

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import com.philkes.notallyx.databinding.FragmentNotesBinding
2727
import com.philkes.notallyx.presentation.activity.main.MainActivity
2828
import com.philkes.notallyx.presentation.activity.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_FOLDER
2929
import com.philkes.notallyx.presentation.activity.main.fragment.SearchFragment.Companion.EXTRA_INITIAL_LABEL
30+
import com.philkes.notallyx.presentation.activity.note.EditActivity
3031
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_FROM
3132
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_FOLDER_TO
3233
import com.philkes.notallyx.presentation.activity.note.EditActivity.Companion.EXTRA_NOTE_ID
@@ -187,6 +188,7 @@ abstract class NotallyFragment : Fragment(), ItemListener {
187188
binding?.EnterSearchKeywordLayout?.visibility = View.VISIBLE
188189
requestFocus()
189190
activity?.showKeyboard(this)
191+
notesAdapter?.setSearchKeyword(model.keyword)
190192
} else {
191193
// In other fragments, respect the preference
192194
val alwaysShowSearchBar = model.preferences.alwaysShowSearchBar.value
@@ -195,16 +197,18 @@ abstract class NotallyFragment : Fragment(), ItemListener {
195197
setText("")
196198
clearFocus()
197199
activity?.hideKeyboard(this)
200+
notesAdapter?.setSearchKeyword("")
198201
}
199202
}
200203
doAfterTextChanged { text ->
201204
val isSearchFragment = navController.currentDestination?.id == R.id.Search
202205
if (isSearchFragment) {
203-
model.keyword = requireNotNull(text, { "text is null" }).trim().toString()
206+
model.keyword = requireNotNull(text, { "text is null" }).toString()
207+
notesAdapter?.apply { setSearchKeyword(model.keyword) }
204208
}
205209
if (text?.isNotEmpty() == true && !isSearchFragment) {
206210
setText("")
207-
model.keyword = text.trim().toString()
211+
model.keyword = text.toString()
208212
navController.navigate(
209213
R.id.Search,
210214
Bundle().apply {
@@ -213,6 +217,9 @@ abstract class NotallyFragment : Fragment(), ItemListener {
213217
},
214218
)
215219
}
220+
this@NotallyFragment.binding?.MainListView?.apply {
221+
postOnAnimationDelayed({ scrollToPosition(0) }, 10)
222+
}
216223
}
217224
}
218225
}
@@ -299,6 +306,12 @@ abstract class NotallyFragment : Fragment(), ItemListener {
299306
private fun goToActivity(activity: Class<*>, baseNote: BaseNote) {
300307
val intent = Intent(requireContext(), activity)
301308
intent.putExtra(EXTRA_SELECTED_BASE_NOTE, baseNote.id)
309+
// If launched from Search fragment with a non-empty keyword, pass it to the editor to
310+
// auto-highlight
311+
val isInSearch = view?.findNavController()?.currentDestination?.id == R.id.Search
312+
if (isInSearch && model.keyword.isNotBlank()) {
313+
intent.putExtra(EditActivity.EXTRA_INITIAL_SEARCH_QUERY, model.keyword)
314+
}
302315
openNoteActivityResultLauncher.launch(intent)
303316
}
304317

app/src/main/java/com/philkes/notallyx/presentation/activity/note/EditActivity.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,21 @@ abstract class EditActivity(private val type: Type) :
236236
}
237237
}
238238

239+
override fun onStart() {
240+
super.onStart()
241+
// If launched with an initial search query (from global search), auto-start in-note search
242+
intent.getStringExtra(EXTRA_INITIAL_SEARCH_QUERY)?.let { initialQuery ->
243+
if (initialQuery.isNotBlank()) {
244+
binding.EnterSearchKeyword.postOnAnimation {
245+
startSearch()
246+
binding.EnterSearchKeyword.setText(initialQuery)
247+
binding.EnterSearchKeyword.setSelection(initialQuery.length)
248+
}
249+
}
250+
intent.removeExtra(EXTRA_INITIAL_SEARCH_QUERY)
251+
}
252+
}
253+
239254
private fun configureEdgeToEdgeInsets() {
240255
WindowCompat.setDecorFitsSystemWindows(window, false)
241256

@@ -1242,6 +1257,7 @@ abstract class EditActivity(private val type: Type) :
12421257
const val EXTRA_NOTE_ID = "notallyx.intent.extra.NOTE_ID"
12431258
const val EXTRA_FOLDER_FROM = "notallyx.intent.extra.FOLDER_FROM"
12441259
const val EXTRA_FOLDER_TO = "notallyx.intent.extra.FOLDER_TO"
1260+
const val EXTRA_INITIAL_SEARCH_QUERY = "notallyx.intent.extra.INITIAL_SEARCH_QUERY"
12451261

12461262
val DEFAULT_EXCEPTION_HANDLER = Thread.getDefaultUncaughtExceptionHandler()
12471263
}

app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteAdapter.kt

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ class BaseNoteAdapter(
2929
private val listener: ItemListener,
3030
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
3131

32+
private var searchKeyword: String = ""
33+
3234
private var list = SortedList(Item::class.java, notesSort.createCallback())
3335

3436
override fun getItemViewType(position: Int): Int {
@@ -45,13 +47,19 @@ class BaseNoteAdapter(
4547
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
4648
when (val item = list[position]) {
4749
is Header -> (holder as HeaderVH).bind(item)
48-
is BaseNote ->
49-
(holder as BaseNoteVH).bind(
50-
item,
51-
imageRoot,
52-
selectedIds.contains(item.id),
53-
notesSort.sortedBy,
54-
)
50+
is BaseNote -> {
51+
(holder as BaseNoteVH).apply {
52+
setSearchKeyword(searchKeyword)
53+
bind(item, imageRoot, selectedIds.contains(item.id), notesSort.sortedBy)
54+
}
55+
}
56+
}
57+
}
58+
59+
fun setSearchKeyword(keyword: String) {
60+
if (searchKeyword != keyword) {
61+
searchKeyword = keyword
62+
notifyDataSetChanged()
5563
}
5664
}
5765

app/src/main/java/com/philkes/notallyx/presentation/view/main/BaseNoteVH.kt

Lines changed: 98 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import com.philkes.notallyx.presentation.extractColor
3434
import com.philkes.notallyx.presentation.getQuantityString
3535
import com.philkes.notallyx.presentation.setControlsContrastColorForAllViews
3636
import com.philkes.notallyx.presentation.view.misc.ItemListener
37+
import com.philkes.notallyx.presentation.view.misc.highlightableview.HighlightableTextView
38+
import com.philkes.notallyx.presentation.view.misc.highlightableview.SEARCH_SNIPPET_ITEM_LINES
3739
import com.philkes.notallyx.presentation.view.note.listitem.init
3840
import com.philkes.notallyx.presentation.viewmodel.preference.DateFormat
3941
import com.philkes.notallyx.presentation.viewmodel.preference.NotesSortBy
@@ -56,6 +58,12 @@ class BaseNoteVH(
5658
listener: ItemListener,
5759
) : RecyclerView.ViewHolder(binding.root) {
5860

61+
private var searchKeyword: String = ""
62+
63+
fun setSearchKeyword(keyword: String) {
64+
this.searchKeyword = keyword
65+
}
66+
5967
init {
6068
val title = preferences.textSize.displayTitleSize
6169
val body = preferences.textSize.displayBodySize
@@ -95,8 +103,8 @@ class BaseNoteVH(
95103
updateCheck(checked, baseNote.color)
96104

97105
when (baseNote.type) {
98-
Type.NOTE -> bindNote(baseNote.body, baseNote.spans, baseNote.title.isEmpty())
99-
Type.LIST -> bindList(baseNote.items, baseNote.title.isEmpty())
106+
Type.NOTE -> bindNote(baseNote, searchKeyword)
107+
Type.LIST -> bindList(baseNote, searchKeyword)
100108
}
101109
val (date, datePrefixResId) =
102110
when (sortBy) {
@@ -111,12 +119,18 @@ class BaseNoteVH(
111119
setFiles(baseNote.files)
112120

113121
binding.Title.apply {
114-
text = baseNote.title
115122
isVisible = baseNote.title.isNotEmpty()
116123
updatePadding(
117124
bottom =
118125
if (baseNote.hasNoContents() || shouldOnlyDisplayTitle(baseNote)) 0 else 8.dp
119126
)
127+
if (searchKeyword.isNotBlank()) {
128+
val snippet = extractSearchSnippet(baseNote.title, searchKeyword)
129+
if (snippet != null) {
130+
showSearchSnippet(snippet)
131+
} else text = baseNote.title
132+
} else text = baseNote.title
133+
120134
setCompoundDrawablesWithIntrinsicBounds(
121135
if (baseNote.type == Type.LIST && preferences.maxItems < 1)
122136
R.drawable.checkbox_small
@@ -148,9 +162,23 @@ class BaseNoteVH(
148162
binding.RemindersView.isVisible = baseNote.reminders.any { it.hasUpcomingNotification() }
149163
}
150164

151-
private fun bindNote(body: String, spans: List<SpanRepresentation>, isTitleEmpty: Boolean) {
165+
private fun bindNote(baseNote: BaseNote, keyword: String) {
152166
binding.LinearLayout.visibility = GONE
167+
if (keyword.isBlank()) {
168+
bindNote(baseNote.body, baseNote.spans, baseNote.title.isEmpty())
169+
return
170+
}
171+
binding.Note.apply {
172+
val snippet = extractSearchSnippet(baseNote.body, keyword)
173+
if (snippet == null) {
174+
bindNote(baseNote.body, baseNote.spans, baseNote.title.isEmpty())
175+
} else {
176+
showSearchSnippet(snippet)
177+
}
178+
}
179+
}
153180

181+
private fun bindNote(body: String, spans: List<SpanRepresentation>, isTitleEmpty: Boolean) {
154182
binding.Note.apply {
155183
text = body.applySpans(spans)
156184
if (preferences.maxLines < 1) {
@@ -162,44 +190,91 @@ class BaseNoteVH(
162190
}
163191
}
164192

165-
private fun bindList(items: List<ListItem>, isTitleEmpty: Boolean) {
193+
/** Shows a snippet of ListItems around the ListItem that contains keyword */
194+
private fun LinearLayout.bindListSearch(
195+
initializedItems: List<ListItem>,
196+
keyword: String,
197+
isTitleEmpty: Boolean,
198+
) {
199+
binding.LinearLayout.visibility = VISIBLE
200+
val keywordItemIdx =
201+
initializedItems.indexOfFirst { it.body.contains(keyword, ignoreCase = true) }
202+
if (keywordItemIdx == -1) {
203+
return bindList(initializedItems, isTitleEmpty)
204+
}
205+
val listItemViews = children.filterIsInstance(HighlightableTextView::class.java).toList()
206+
listItemViews.forEach { it.visibility = GONE }
207+
val startItemIdx = (keywordItemIdx - SEARCH_SNIPPET_ITEM_LINES).coerceAtLeast(0)
208+
val endItemIdx =
209+
(keywordItemIdx + SEARCH_SNIPPET_ITEM_LINES).coerceAtMost(initializedItems.lastIndex)
210+
(startItemIdx..endItemIdx).forEachIndexed { viewIdx, itemIdx ->
211+
listItemViews[viewIdx].apply {
212+
val item = initializedItems[itemIdx]
213+
text = item.body
214+
if (itemIdx == keywordItemIdx) {
215+
highlight(keyword)
216+
}
217+
handleChecked(this, item.checked)
218+
visibility = VISIBLE
219+
updateLayoutParams<LinearLayout.LayoutParams> {
220+
marginStart = if (item.isChild) 20.dp else 0
221+
}
222+
}
223+
}
224+
bindItemsRemaining(initializedItems.size, endItemIdx - startItemIdx + 1)
225+
}
226+
227+
private fun bindList(baseNote: BaseNote, keyword: String) {
228+
binding.Note.visibility = GONE
229+
val initializedItems = baseNote.items.init()
230+
if (baseNote.items.isEmpty()) {
231+
binding.LinearLayout.visibility = GONE
232+
return
233+
}
234+
if (keyword.isBlank()) {
235+
bindList(initializedItems, baseNote.title.isEmpty())
236+
return
237+
}
238+
binding.LinearLayout.bindListSearch(initializedItems, keyword, baseNote.title.isEmpty())
239+
}
240+
241+
private fun bindItemsRemaining(totalItems: Int, displayedItems: Int) {
242+
if (displayedItems > 0 && totalItems > displayedItems) {
243+
binding.ItemsRemaining.apply {
244+
visibility = VISIBLE
245+
text = (totalItems - displayedItems).toString()
246+
}
247+
} else binding.ItemsRemaining.visibility = GONE
248+
}
249+
250+
private fun bindList(initializedItems: List<ListItem>, isTitleEmpty: Boolean) {
166251
binding.apply {
167-
Note.visibility = GONE
168-
if (items.isEmpty()) {
252+
bindItemsRemaining(initializedItems.size, preferences.maxItems)
253+
if (initializedItems.isEmpty()) {
169254
LinearLayout.visibility = GONE
170255
} else {
171256
LinearLayout.visibility = VISIBLE
172257
val forceShowFirstItem = preferences.maxItems < 1 && isTitleEmpty
173-
val initializedItems = items.init()
174258
val filteredList =
175259
initializedItems.take(if (forceShowFirstItem) 1 else preferences.maxItems)
176-
LinearLayout.children.forEachIndexed { index, view ->
177-
if (view.id != R.id.ItemsRemaining) {
260+
LinearLayout.children
261+
.filterIsInstance(HighlightableTextView::class.java)
262+
.forEachIndexed { index, view ->
178263
if (index < filteredList.size) {
179264
val item = filteredList[index]
180-
(view as TextView).apply {
265+
view.apply {
181266
text = item.body
182267
handleChecked(this, item.checked)
183268
visibility = VISIBLE
184-
if (item.isChild) {
185-
updateLayoutParams<LinearLayout.LayoutParams> {
186-
marginStart = 20.dp
187-
}
269+
updateLayoutParams<LinearLayout.LayoutParams> {
270+
marginStart = if (item.isChild) 20.dp else 0
188271
}
189272
if (index == filteredList.lastIndex) {
190273
updatePadding(bottom = 0)
191274
}
192275
}
193276
} else view.visibility = GONE
194277
}
195-
}
196-
197-
if (preferences.maxItems > 0 && items.size > preferences.maxItems) {
198-
ItemsRemaining.apply {
199-
visibility = VISIBLE
200-
text = (items.size - preferences.maxItems).toString()
201-
}
202-
} else ItemsRemaining.visibility = GONE
203278
}
204279
}
205280
}

app/src/main/java/com/philkes/notallyx/presentation/view/misc/EditTextAutoClearFocus.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.philkes.notallyx.presentation.view.misc
33
import android.content.Context
44
import android.util.AttributeSet
55
import android.view.KeyEvent
6+
import com.philkes.notallyx.presentation.view.misc.highlightableview.HighlightableEditText
67

78
class EditTextAutoClearFocus(context: Context, attributeSet: AttributeSet) :
89
HighlightableEditText(context, attributeSet) {

app/src/main/java/com/philkes/notallyx/presentation/view/misc/StylableEditTextWithHistory.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import com.philkes.notallyx.presentation.createTextWatcherWithHistory
2424
import com.philkes.notallyx.presentation.removeSelectionFromSpans
2525
import com.philkes.notallyx.presentation.setCancelButton
2626
import com.philkes.notallyx.presentation.showAndFocus
27+
import com.philkes.notallyx.presentation.view.misc.highlightableview.HighlightableEditText
2728
import com.philkes.notallyx.utils.changehistory.ChangeHistory
2829
import com.philkes.notallyx.utils.changehistory.EditTextState
2930
import com.philkes.notallyx.utils.changehistory.EditTextWithHistoryChange
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package com.philkes.notallyx.presentation.view.misc.highlightableview
2+
3+
import android.text.style.BackgroundColorSpan
4+
import androidx.annotation.ColorInt
5+
6+
class HighlightSpan(@ColorInt color: Int) : BackgroundColorSpan(color)

app/src/main/java/com/philkes/notallyx/presentation/view/misc/HighlightableEditText.kt renamed to app/src/main/java/com/philkes/notallyx/presentation/view/misc/highlightableview/HighlightableEditText.kt

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
package com.philkes.notallyx.presentation.view.misc
1+
package com.philkes.notallyx.presentation.view.misc.highlightableview
22

33
import android.content.Context
44
import android.text.Spanned
5-
import android.text.style.BackgroundColorSpan
65
import android.text.style.CharacterStyle
76
import android.util.AttributeSet
8-
import androidx.annotation.ColorInt
97
import androidx.appcompat.widget.AppCompatEditText
108
import com.philkes.notallyx.presentation.removeSelectionFromSpans
9+
import com.philkes.notallyx.presentation.view.misc.EditTextWithWatcher
1110
import com.philkes.notallyx.presentation.withAlpha
1211

1312
/**
@@ -95,5 +94,3 @@ open class HighlightableEditText(context: Context, attrs: AttributeSet) :
9594
}
9695
}
9796
}
98-
99-
class HighlightSpan(@ColorInt color: Int) : BackgroundColorSpan(color)

0 commit comments

Comments
 (0)