Skip to content

Commit

Permalink
Support search history
Browse files Browse the repository at this point in the history
  • Loading branch information
5ec1cff committed Oct 5, 2023
1 parent b7477a7 commit 628eb43
Show file tree
Hide file tree
Showing 11 changed files with 220 additions and 29 deletions.
11 changes: 10 additions & 1 deletion app/src/main/java/io/github/a13e300/ro_tieba/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ import com.github.panpf.sketch.request.PauseLoadWhenScrollingDrawableDecodeInter
import com.google.gson.Gson
import io.github.a13e300.ro_tieba.account.AccountManager
import io.github.a13e300.ro_tieba.api.TiebaClient
import io.github.a13e300.ro_tieba.datastore.SearchHistory
import io.github.a13e300.ro_tieba.datastore.Settings
import io.github.a13e300.ro_tieba.db.AppDataBase
import io.github.a13e300.ro_tieba.history.HistoryManager
import io.github.a13e300.ro_tieba.serializer.SearchHistorySerializer
import io.github.a13e300.ro_tieba.serializer.SettingsSerializer
import io.github.a13e300.ro_tieba.utils.ignoreAllSSLErrorsIfDebug
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
Expand All @@ -35,17 +38,23 @@ class App : Application(), SketchFactory {
lateinit var db: AppDataBase
lateinit var client: TiebaClient
val accountManager = AccountManager()
val historyManager = HistoryManager()
lateinit var historyManager: HistoryManager

val settingsDataStore: DataStore<Settings> by dataStore(
fileName = "settings.pb",
serializer = SettingsSerializer
)

val searchHistoryDataStore: DataStore<SearchHistory> by dataStore(
fileName = "search_history.pb",
serializer = SearchHistorySerializer
)

override fun onCreate() {
super.onCreate()
instance = this
db = Room.databaseBuilder(this, AppDataBase::class.java, "app-db").build()
historyManager = HistoryManager()
runBlocking {
accountManager.initAccount()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,42 @@
package io.github.a13e300.ro_tieba.history

import io.github.a13e300.ro_tieba.App
import io.github.a13e300.ro_tieba.datastore.SearchHistory
import io.github.a13e300.ro_tieba.db.HistoryEntry
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class HistoryManager {
companion object {
const val MAX_HISTORY_ENTRY_COUNT = 300
const val MAX_SEARCH_HISTORY_COUNT = 15
}

suspend fun addSearch(entry: SearchHistory.Entry) {
App.instance.searchHistoryDataStore.updateData { h ->
SearchHistory.newBuilder()
.addEntries(entry)
.addAllEntries(
h.entriesList.filter { it.keyword != entry.keyword }.take(
MAX_SEARCH_HISTORY_COUNT - 1
)
)
.build()
}
}

suspend fun removeSearch(entry: SearchHistory.Entry) {
App.instance.searchHistoryDataStore.updateData { h ->
SearchHistory.newBuilder()
.addAllEntries(h.entriesList.filter { it.keyword != entry.keyword })
.build()
}
}

suspend fun clearSearch() {
App.instance.searchHistoryDataStore.updateData { _ ->
SearchHistory.getDefaultInstance()
}
}

suspend fun updateHistory(entry: HistoryEntry) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package io.github.a13e300.ro_tieba.serializer

import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.google.protobuf.InvalidProtocolBufferException
import io.github.a13e300.ro_tieba.datastore.SearchHistory
import java.io.InputStream
import java.io.OutputStream

object SearchHistorySerializer :
Serializer<SearchHistory> {
override val defaultValue: SearchHistory = SearchHistory.getDefaultInstance()

override suspend fun readFrom(input: InputStream): SearchHistory {
try {
return SearchHistory.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}

override suspend fun writeTo(
t: SearchHistory,
output: OutputStream
) = t.writeTo(output)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package io.github.a13e300.ro_tieba
package io.github.a13e300.ro_tieba.serializer

import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
Expand All @@ -23,4 +23,3 @@ object SettingsSerializer : Serializer<Settings> {
output: OutputStream
) = t.writeTo(output)
}

Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ class SearchForumFragment : Fragment() {
(viewModel.searchedForums.value as? SearchState.Result)?.data?.get(position)
?: return
holder.binding.barName.text = item.name
item.avatarUrl?.let { holder.binding.barAvatar.displayImageInList(it) }
item.avatarUrl.let { holder.binding.barAvatar.displayImageInList(it) }
holder.binding.barDesc.apply {
if (!item.desc.isNullOrEmpty()) {
text = item.desc
Expand Down
125 changes: 104 additions & 21 deletions app/src/main/java/io/github/a13e300/ro_tieba/ui/search/SearchFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,38 +3,47 @@ package io.github.a13e300.ro_tieba.ui.search
import android.annotation.SuppressLint
import android.content.ClipboardManager
import android.os.Bundle
import android.text.InputFilter.LengthFilter
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.search.SearchView
import com.google.android.material.tabs.TabLayoutMediator
import io.github.a13e300.ro_tieba.App
import io.github.a13e300.ro_tieba.BaseFragment
import io.github.a13e300.ro_tieba.MobileNavigationDirections
import io.github.a13e300.ro_tieba.R
import io.github.a13e300.ro_tieba.arch.Event
import io.github.a13e300.ro_tieba.databinding.FragmentSearchBinding
import io.github.a13e300.ro_tieba.databinding.SearchSuggestionItemBinding
import io.github.a13e300.ro_tieba.datastore.SearchHistory
import io.github.a13e300.ro_tieba.misc.OnPreImeBackPressedListener
import io.github.a13e300.ro_tieba.models.PostId
import io.github.a13e300.ro_tieba.ui.thread.ThreadFragmentDirections
import io.github.a13e300.ro_tieba.utils.navigateToPost
import io.github.a13e300.ro_tieba.utils.parseThreadLink
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

val USER_REGEX = Regex("\\d+|tb\\.1\\..*")
val THREAD_REGEX = Regex("tieba\\.baidu\\.com/p/(\\d+)(.*[\\?&]pid=(\\d+))?")

@SuppressLint("NotifyDataSetChanged")
class SearchFragment : BaseFragment() {
Expand Down Expand Up @@ -72,6 +81,10 @@ class SearchFragment : BaseFragment() {
else getString(R.string.searchbar_hint)
binding.searchView.hint = hint
binding.searchBar.hint = hint
binding.searchView.editText.apply {
filters = arrayOf(LengthFilter(100))
maxLines = 1
}
binding.searchView.editText.setOnEditorActionListener { textView, i, keyEvent ->
if (i != EditorInfo.IME_ACTION_SEARCH &&
!(keyEvent?.action == KeyEvent.ACTION_DOWN
Expand All @@ -98,8 +111,8 @@ class SearchFragment : BaseFragment() {
requireContext().getSystemService(ClipboardManager::class.java).primaryClip?.getItemAt(
0
)?.text
updateOperations()
}
updateOperations()
}
}
binding.searchViewPager.adapter = object : FragmentStateAdapter(this) {
Expand Down Expand Up @@ -127,6 +140,11 @@ class SearchFragment : BaseFragment() {
else navigateUp()
return@OnPreImeBackPressedListener true
}
mSuggestionAdapter = SearchSuggestionAdapter()
binding.searchSuggestions.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = mSuggestionAdapter
}
if (viewModel.searchAtForum) {
binding.searchTabLayout.isGone = true
binding.searchViewPager.isUserInputEnabled = false
Expand All @@ -141,7 +159,6 @@ class SearchFragment : BaseFragment() {
)
}
})
mSuggestionAdapter = SearchSuggestionAdapter()
TabLayoutMediator(binding.searchTabLayout, binding.searchViewPager) { tab, position ->
tab.text = when (position) {
0 -> getString(R.string.search_tab_bar_title)
Expand All @@ -153,10 +170,6 @@ class SearchFragment : BaseFragment() {
mSearchText = e?.toString() ?: ""
updateOperations()
}
binding.searchSuggestions.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = mSuggestionAdapter
}
viewModel.forumCount.observe(viewLifecycleOwner) {
binding.searchTabLayout.getTabAt(0)?.text =
if (it == null) getString(R.string.search_tab_bar_title)
Expand All @@ -168,29 +181,42 @@ class SearchFragment : BaseFragment() {
else getString(R.string.search_tab_user_title) + "($it)"
}
}
lifecycleScope.launch {
App.instance.searchHistoryDataStore.data.collectLatest {
updateOperations()
}
}
return binding.root
}

private fun updateOperations() {
mSuggestions.clear()
val l = mSuggestions
val s = mSearchText
if (s.isNotEmpty()) {
l.add(Operation.GoToForum(s))
s.toLongOrNull()?.also { l.add(Operation.GoToThread(PostId.Thread(it))) }
s.parseThreadLink()?.also { l.add(Operation.GoToThread(it)) }
if (s.matches(USER_REGEX)) {
l.add(Operation.GoToUser(s))
if (!viewModel.searchAtForum) {
if (s.isNotEmpty()) {
l.add(Operation.GoToForum(s))
s.toLongOrNull()?.also { l.add(Operation.GoToThread(PostId.Thread(it))) }
s.parseThreadLink()?.also { l.add(Operation.GoToThread(it)) }
if (s.matches(USER_REGEX)) {
l.add(Operation.GoToUser(s))
}
l.add(Operation.SearchForum(s))
l.add(Operation.SearchPosts(s))
l.add(Operation.SearchUsers(s))
}
l.add(Operation.SearchForum(s))
l.add(Operation.SearchPosts(s))
l.add(Operation.SearchUsers(s))
}
mClipboardContent?.also { clip ->
clip.toString().parseThreadLink()?.also {
l.add(Operation.GoToThread(it, true))
mClipboardContent?.also { clip ->
clip.toString().parseThreadLink()?.also {
l.add(Operation.GoToThread(it, true))
}
}
}
if (s.isEmpty()) {
val histories =
runBlocking { App.instance.searchHistoryDataStore.data.first().entriesList }
l.addAll(histories.map { Operation.History(it) })
if (histories.isNotEmpty()) l.add(Operation.RemoveHistories)
}
mSuggestionAdapter.notifyDataSetChanged()
}

Expand All @@ -205,6 +231,36 @@ class SearchFragment : BaseFragment() {
viewModel.searchPostEvent.value = Event(t)
viewModel.forumCount.value = null
viewModel.userCount.value = null
lifecycleScope.launch {
App.instance.historyManager.addSearch(
SearchHistory.Entry.newBuilder().setKeyword(t).build()
)
}
}

private fun removeHistoryDialog(entry: SearchHistory.Entry) {
MaterialAlertDialogBuilder(requireContext())
.setTitle("确定要删除吗?")
.setMessage(entry.keyword)
.setPositiveButton("删除") { _, _ ->
lifecycleScope.launch {
App.instance.historyManager.removeSearch(entry)
}
}
.setNegativeButton("取消", null)
.show()
}

private fun removeAllHistoriesDialog() {
MaterialAlertDialogBuilder(requireContext())
.setTitle("确定要删除所有历史记录吗?")
.setPositiveButton("删除") { _, _ ->
lifecycleScope.launch {
App.instance.historyManager.clearSearch()
}
}
.setNegativeButton("取消", null)
.show()
}

class SearchSuggestionViewHolder(val binding: SearchSuggestionItemBinding) :
Expand All @@ -213,7 +269,34 @@ class SearchFragment : BaseFragment() {
inner class SearchSuggestionAdapter : RecyclerView.Adapter<SearchSuggestionViewHolder>() {
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: SearchSuggestionViewHolder, position: Int) {
when (val op = mSuggestions[position]) {
holder.binding.root.setOnClickListener(null)
holder.binding.root.setOnLongClickListener(null)
val op = mSuggestions[position]
holder.binding.inputButton.isVisible =
op is Operation.History && !viewModel.searchAtForum
when (op) {
is Operation.History -> {
val kw = op.entry.keyword
holder.binding.title.text = kw
holder.binding.root.setOnClickListener {
performSearch(kw, -1)
}
holder.binding.root.setOnLongClickListener {
removeHistoryDialog(op.entry)
true
}
holder.binding.inputButton.setOnClickListener {
binding.searchView.editText.setText(kw)
}
}

is Operation.RemoveHistories -> {
holder.binding.title.text = "清空历史记录"
holder.binding.root.setOnClickListener {
removeAllHistoriesDialog()
}
}

is Operation.GoToForum -> {
holder.binding.title.text = "进吧:${op.name}"
holder.binding.root.setOnClickListener {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import io.github.a13e300.ro_tieba.api.TiebaClient
import io.github.a13e300.ro_tieba.api.web.SearchFilter
import io.github.a13e300.ro_tieba.api.web.SearchOrder
import io.github.a13e300.ro_tieba.arch.Event
import io.github.a13e300.ro_tieba.datastore.SearchHistory
import io.github.a13e300.ro_tieba.models.Forum
import io.github.a13e300.ro_tieba.models.PostId
import io.github.a13e300.ro_tieba.models.SearchedPost
Expand All @@ -28,6 +29,8 @@ import kotlinx.coroutines.withContext
import java.util.Date

sealed class Operation {
data class History(val entry: SearchHistory.Entry) : Operation()
data object RemoveHistories : Operation()
data class GoToForum(val name: String) : Operation()
data class GoToThread(val id: PostId, val fromClip: Boolean = false) : Operation()

Expand Down
11 changes: 11 additions & 0 deletions app/src/main/protobuf/datastore/SearchHistory.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
syntax = "proto3";

option java_package = "io.github.a13e300.ro_tieba.datastore";
option java_multiple_files = true;

message SearchHistory {
message Entry {
string keyword = 1;
}
repeated Entry entries = 1;
}
11 changes: 11 additions & 0 deletions app/src/main/res/drawable/ic_input.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<vector android:autoMirrored="true"
android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp"
xmlns:android="http://schemas.android.com/apk/res/android">
<path
android:fillColor="@android:color/white"
android:pathData="M21,3.01H3c-1.1,0 -2,0.9 -2,2V9h2V4.99h18v14.03H3V15H1v4.01c0,1.1 0.9,1.98 2,1.98h18c1.1,0 2,-0.88 2,-1.98v-14c0,-1.11 -0.9,-2 -2,-2zM11,16l4,-4 -4,-4v3H1v2h10v3z" />
</vector>
Loading

0 comments on commit 628eb43

Please sign in to comment.