feat: 搜索联想

This commit is contained in:
HuanCheng65 2023-10-05 01:47:01 +08:00
parent 87ffe0ec6a
commit 7701fceff0
No known key found for this signature in database
GPG Key ID: 5EC9DD60A32C7360
15 changed files with 329 additions and 16 deletions

View File

@ -12,6 +12,7 @@ import com.huanchengfly.tieba.post.api.models.protos.pbFloor.PbFloorResponse
import com.huanchengfly.tieba.post.api.models.protos.pbPage.PbPageResponse import com.huanchengfly.tieba.post.api.models.protos.pbPage.PbPageResponse
import com.huanchengfly.tieba.post.api.models.protos.personalized.PersonalizedResponse import com.huanchengfly.tieba.post.api.models.protos.personalized.PersonalizedResponse
import com.huanchengfly.tieba.post.api.models.protos.profile.ProfileResponse import com.huanchengfly.tieba.post.api.models.protos.profile.ProfileResponse
import com.huanchengfly.tieba.post.api.models.protos.searchSug.SearchSugResponse
import com.huanchengfly.tieba.post.api.models.protos.threadList.ThreadListResponse import com.huanchengfly.tieba.post.api.models.protos.threadList.ThreadListResponse
import com.huanchengfly.tieba.post.api.models.protos.topicList.TopicListResponse import com.huanchengfly.tieba.post.api.models.protos.topicList.TopicListResponse
import com.huanchengfly.tieba.post.api.models.protos.userLike.UserLikeResponse import com.huanchengfly.tieba.post.api.models.protos.userLike.UserLikeResponse
@ -1378,6 +1379,17 @@ interface ITiebaApi {
postId: Long, postId: Long,
forumId: Long = 0L, forumId: Long = 0L,
page: Int = 1, page: Int = 1,
subPostId: Long = 0L subPostId: Long = 0L,
): Flow<PbFloorResponse> ): Flow<PbFloorResponse>
/**
* 搜索联想
*
* @param keyword 关键词
* @param isForum 是否为吧
*/
fun searchSuggestionsFlow(
keyword: String,
isForum: Boolean = false,
): Flow<SearchSugResponse>
} }

View File

@ -72,6 +72,9 @@ import com.huanchengfly.tieba.post.api.models.protos.personalized.PersonalizedRe
import com.huanchengfly.tieba.post.api.models.protos.profile.ProfileRequest import com.huanchengfly.tieba.post.api.models.protos.profile.ProfileRequest
import com.huanchengfly.tieba.post.api.models.protos.profile.ProfileRequestData import com.huanchengfly.tieba.post.api.models.protos.profile.ProfileRequestData
import com.huanchengfly.tieba.post.api.models.protos.profile.ProfileResponse import com.huanchengfly.tieba.post.api.models.protos.profile.ProfileResponse
import com.huanchengfly.tieba.post.api.models.protos.searchSug.SearchSugRequest
import com.huanchengfly.tieba.post.api.models.protos.searchSug.SearchSugRequestData
import com.huanchengfly.tieba.post.api.models.protos.searchSug.SearchSugResponse
import com.huanchengfly.tieba.post.api.models.protos.threadList.AdParam import com.huanchengfly.tieba.post.api.models.protos.threadList.AdParam
import com.huanchengfly.tieba.post.api.models.protos.threadList.ThreadListRequest import com.huanchengfly.tieba.post.api.models.protos.threadList.ThreadListRequest
import com.huanchengfly.tieba.post.api.models.protos.threadList.ThreadListRequestData import com.huanchengfly.tieba.post.api.models.protos.threadList.ThreadListRequestData
@ -1240,4 +1243,20 @@ object MixedTiebaApiImpl : ITiebaApi {
) )
) )
} }
override fun searchSuggestionsFlow(keyword: String, isForum: Boolean): Flow<SearchSugResponse> {
return RetrofitTiebaApi.OFFICIAL_PROTOBUF_TIEBA_V12_API.searchSugFlow(
buildProtobufRequestBody(
SearchSugRequest(
SearchSugRequestData(
common = buildCommonRequest(clientVersion = ClientVersion.TIEBA_V12),
word = keyword,
isforum = isForum.booleanToString()
)
),
clientVersion = ClientVersion.TIEBA_V12,
needSToken = true
)
)
}
} }

View File

@ -8,6 +8,7 @@ import com.huanchengfly.tieba.post.api.models.protos.pbFloor.PbFloorResponse
import com.huanchengfly.tieba.post.api.models.protos.pbPage.PbPageResponse import com.huanchengfly.tieba.post.api.models.protos.pbPage.PbPageResponse
import com.huanchengfly.tieba.post.api.models.protos.personalized.PersonalizedResponse import com.huanchengfly.tieba.post.api.models.protos.personalized.PersonalizedResponse
import com.huanchengfly.tieba.post.api.models.protos.profile.ProfileResponse import com.huanchengfly.tieba.post.api.models.protos.profile.ProfileResponse
import com.huanchengfly.tieba.post.api.models.protos.searchSug.SearchSugResponse
import com.huanchengfly.tieba.post.api.models.protos.threadList.ThreadListResponse import com.huanchengfly.tieba.post.api.models.protos.threadList.ThreadListResponse
import com.huanchengfly.tieba.post.api.models.protos.topicList.TopicListResponse import com.huanchengfly.tieba.post.api.models.protos.topicList.TopicListResponse
import com.huanchengfly.tieba.post.api.models.protos.userLike.UserLikeResponse import com.huanchengfly.tieba.post.api.models.protos.userLike.UserLikeResponse
@ -73,4 +74,9 @@ interface OfficialProtobufTiebaApi {
fun addPostFlow( fun addPostFlow(
@Body body: MyMultipartBody, @Body body: MyMultipartBody,
): Flow<AddPostResponse> ): Flow<AddPostResponse>
@POST("/c/s/searchSug?cmd=309438&format=protobuf")
fun searchSugFlow(
@Body body: MyMultipartBody,
): Flow<SearchSugResponse>
} }

View File

@ -23,6 +23,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
@ -57,6 +59,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
@ -93,6 +96,7 @@ import com.huanchengfly.tieba.post.ui.page.search.user.SearchUserPage
import com.huanchengfly.tieba.post.ui.widgets.compose.BaseTextField import com.huanchengfly.tieba.post.ui.widgets.compose.BaseTextField
import com.huanchengfly.tieba.post.ui.widgets.compose.Button import com.huanchengfly.tieba.post.ui.widgets.compose.Button
import com.huanchengfly.tieba.post.ui.widgets.compose.ClickMenu import com.huanchengfly.tieba.post.ui.widgets.compose.ClickMenu
import com.huanchengfly.tieba.post.ui.widgets.compose.Container
import com.huanchengfly.tieba.post.ui.widgets.compose.LazyLoadHorizontalPager import com.huanchengfly.tieba.post.ui.widgets.compose.LazyLoadHorizontalPager
import com.huanchengfly.tieba.post.ui.widgets.compose.MyBackHandler import com.huanchengfly.tieba.post.ui.widgets.compose.MyBackHandler
import com.huanchengfly.tieba.post.ui.widgets.compose.MyScaffold import com.huanchengfly.tieba.post.ui.widgets.compose.MyScaffold
@ -107,6 +111,8 @@ import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -121,7 +127,7 @@ data class SearchPageItem(
val onSelectedSortTypeChange: (Int) -> Unit = {}, val onSelectedSortTypeChange: (Int) -> Unit = {},
) )
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class, FlowPreview::class)
@Destination @Destination
@Composable @Composable
fun SearchPage( fun SearchPage(
@ -144,7 +150,25 @@ fun SearchPage(
prop1 = SearchUiState::isKeywordEmpty, prop1 = SearchUiState::isKeywordEmpty,
initial = true initial = true
) )
val suggestions by viewModel.uiState.collectPartialAsState(
prop1 = SearchUiState::suggestions,
initial = persistentListOf()
)
val showSuggestions by remember {
derivedStateOf { suggestions.isNotEmpty() }
}
var inputKeyword by remember { mutableStateOf("") } var inputKeyword by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
snapshotFlow { inputKeyword }
.debounce(500)
.collect {
viewModel.send(SearchUiIntent.KeywordInputChanged(it))
}
}
LaunchedEffect(keyword) { LaunchedEffect(keyword) {
if (keyword.isNotEmpty() && keyword != inputKeyword) { if (keyword.isNotEmpty() && keyword != inputKeyword) {
inputKeyword = keyword inputKeyword = keyword
@ -264,12 +288,22 @@ fun SearchPage(
pages[it].content() pages[it].content()
} }
} }
} else {
if (showSuggestions) {
SearchSuggestionList(
suggestions = suggestions,
onItemClick = {
inputKeyword = it
viewModel.send(SearchUiIntent.SubmitKeyword(it))
}
)
} else { } else {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
Container {
SearchHistoryList( SearchHistoryList(
searchHistories = searchHistories, searchHistories = searchHistories,
onSearchHistoryClick = { onSearchHistoryClick = {
@ -286,6 +320,66 @@ fun SearchPage(
} }
} }
} }
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SearchSuggestionList(
suggestions: ImmutableList<String>,
onItemClick: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
items(
items = suggestions,
key = { it }
) {
Container(
modifier = Modifier.animateItemPlacement()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.clickable {
onItemClick(it)
}
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = stringResource(id = R.string.desc_search_sug, it),
tint = ExtendedTheme.colors.text
)
Text(
text = it,
style = MaterialTheme.typography.subtitle2,
modifier = Modifier.weight(1f),
color = ExtendedTheme.colors.text
)
}
}
}
}
}
@Preview("SearchSuggestionList", backgroundColor = 0xFFFFFFFF)
@Composable
private fun SearchSuggestionListPreview() {
TiebaLiteTheme {
Box(
modifier = Modifier.background(ExtendedTheme.colors.topBar)
) {
SearchSuggestionList(
suggestions = persistentListOf("1", "2", "3"),
onItemClick = {}
)
}
}
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable

View File

@ -2,6 +2,8 @@ package com.huanchengfly.tieba.post.ui.page.search
import com.huanchengfly.tieba.post.App import com.huanchengfly.tieba.post.App
import com.huanchengfly.tieba.post.R import com.huanchengfly.tieba.post.R
import com.huanchengfly.tieba.post.api.TiebaApi
import com.huanchengfly.tieba.post.api.models.protos.searchSug.SearchSugResponse
import com.huanchengfly.tieba.post.api.retrofit.exception.getErrorCode import com.huanchengfly.tieba.post.api.retrofit.exception.getErrorCode
import com.huanchengfly.tieba.post.api.retrofit.exception.getErrorMessage import com.huanchengfly.tieba.post.api.retrofit.exception.getErrorMessage
import com.huanchengfly.tieba.post.arch.BaseViewModel import com.huanchengfly.tieba.post.arch.BaseViewModel
@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.litepal.LitePal import org.litepal.LitePal
@ -70,6 +73,8 @@ class SearchViewModel :
.flatMapConcat { it.producePartialChange() }, .flatMapConcat { it.producePartialChange() },
intentFlow.filterIsInstance<SearchUiIntent.SubmitKeyword>() intentFlow.filterIsInstance<SearchUiIntent.SubmitKeyword>()
.flatMapConcat { it.producePartialChange() }, .flatMapConcat { it.producePartialChange() },
intentFlow.filterIsInstance<SearchUiIntent.KeywordInputChanged>()
.flatMapConcat { it.producePartialChange() },
) )
private fun produceInitPartialChange() = flow<SearchPartialChange.Init> { private fun produceInitPartialChange() = flow<SearchPartialChange.Init> {
@ -109,6 +114,21 @@ class SearchViewModel :
) )
} }
} }
private fun SearchUiIntent.KeywordInputChanged.producePartialChange() =
if (keyword.isNotBlank()) {
TiebaApi.getInstance().searchSuggestionsFlow(keyword)
.map<SearchSugResponse, SearchPartialChange.KeywordInputChanged> {
SearchPartialChange.KeywordInputChanged.Success(
it.data_?.list ?: listOf()
)
}
.catch {
emit(SearchPartialChange.KeywordInputChanged.Failure(it.getErrorMessage()))
}
} else {
flowOf(SearchPartialChange.KeywordInputChanged.Success(emptyList()))
}
} }
} }
@ -120,6 +140,8 @@ sealed interface SearchUiIntent : UiIntent {
data class DeleteSearchHistory(val id: Long) : SearchUiIntent data class DeleteSearchHistory(val id: Long) : SearchUiIntent
data class SubmitKeyword(val keyword: String) : SearchUiIntent data class SubmitKeyword(val keyword: String) : SearchUiIntent
data class KeywordInputChanged(val keyword: String) : SearchUiIntent
} }
sealed interface SearchPartialChange : PartialChange<SearchUiState> { sealed interface SearchPartialChange : PartialChange<SearchUiState> {
@ -167,7 +189,10 @@ sealed interface SearchPartialChange : PartialChange<SearchUiState> {
data class SubmitKeyword(val keyword: String) : SearchPartialChange { data class SubmitKeyword(val keyword: String) : SearchPartialChange {
override fun reduce(oldState: SearchUiState): SearchUiState { override fun reduce(oldState: SearchUiState): SearchUiState {
if (keyword.isEmpty()) { if (keyword.isEmpty()) {
return oldState.copy(isKeywordEmpty = true) return oldState.copy(
isKeywordEmpty = true,
suggestions = persistentListOf()
)
} }
val newSearchHistories = (oldState.searchHistories val newSearchHistories = (oldState.searchHistories
.filterNot { it.content == keyword } + SearchHistory(content = keyword)) .filterNot { it.content == keyword } + SearchHistory(content = keyword))
@ -179,12 +204,26 @@ sealed interface SearchPartialChange : PartialChange<SearchUiState> {
) )
} }
} }
sealed class KeywordInputChanged : SearchPartialChange {
override fun reduce(oldState: SearchUiState): SearchUiState = when (this) {
is Success -> oldState.copy(suggestions = suggestions.toImmutableList())
is Failure -> oldState
}
data class Success(val suggestions: List<String>) : KeywordInputChanged()
data class Failure(
val errorMessage: String,
) : KeywordInputChanged()
}
} }
data class SearchUiState( data class SearchUiState(
val keyword: String = "", val keyword: String = "",
val isKeywordEmpty: Boolean = true, val isKeywordEmpty: Boolean = true,
val searchHistories: ImmutableList<SearchHistory> = persistentListOf(), val searchHistories: ImmutableList<SearchHistory> = persistentListOf(),
val suggestions: ImmutableList<String> = persistentListOf(),
) : UiState ) : UiState
sealed interface SearchUiEvent : UiEvent { sealed interface SearchUiEvent : UiEvent {

View File

@ -0,0 +1,14 @@
syntax = "proto3";
package tieba;
option java_package = "com.huanchengfly.tieba.post.api.models.protos";
message ForumInfo {
uint32 forum_id = 1;
string forum_name = 2;
string avatar = 3;
string post_num = 4;
string concern_num = 5;
int32 has_concerned = 6;
}

View File

@ -0,0 +1,14 @@
syntax = "proto3";
package tieba;
option java_package = "com.huanchengfly.tieba.post.api.models.protos";
message RankingParam {
int32 rank_type = 1;
int32 rank_code = 2;
string sort_type = 3;
int32 tab_id = 4;
int32 pn = 5;
int32 rn = 6;
}

View File

@ -0,0 +1,32 @@
syntax = "proto3";
package tieba;
option java_package = "com.huanchengfly.tieba.post.api.models.protos";
import "PbContent.proto";
message RecommendForumInfo {
string avatar = 1;
uint64 forum_id = 2;
string forum_name = 3;
uint32 is_like = 4;
uint32 member_count = 5;
uint32 thread_count = 6;
string slogan = 7;
repeated PbContent content = 8;
uint32 forum_type = 9;
string authen = 10;
string recom_reason = 11;
uint32 is_brand_forum = 12;
string hot_text = 13;
string abtest_tag = 14;
string source = 15;
string extra = 16;
uint32 is_private_forum = 17;
string lv1_name = 18;
string lv2_name = 19;
string avatar_origin = 20;
uint64 hot_thread_id = 22;
int32 is_recommend_forum = 23;
}

View File

@ -0,0 +1,11 @@
syntax = "proto3";
package tieba.searchSug;
option java_package = "com.huanchengfly.tieba.post.api.models.protos.searchSug";
import "SearchSug/SearchSugRequestData.proto";
message SearchSugRequest {
SearchSugRequestData data = 1;
}

View File

@ -0,0 +1,13 @@
syntax = "proto3";
package tieba.searchSug;
option java_package = "com.huanchengfly.tieba.post.api.models.protos.searchSug";
import "CommonRequest.proto";
message SearchSugRequestData {
CommonRequest common = 1;
string word = 2;
string isforum = 3;
}

View File

@ -0,0 +1,13 @@
syntax = "proto3";
package tieba.searchSug;
option java_package = "com.huanchengfly.tieba.post.api.models.protos.searchSug";
import "Error.proto";
import "SearchSug/SearchSugResponseData.proto";
message SearchSugResponse {
Error error = 1;
SearchSugResponseData data = 2;
}

View File

@ -0,0 +1,21 @@
syntax = "proto3";
package tieba.searchSug;
option java_package = "com.huanchengfly.tieba.post.api.models.protos.searchSug";
import "ForumInfo.proto";
import "Item.proto";
import "RecommendForumInfo.proto";
import "SugLiveInfo.proto";
import "SugRankingInfo.proto";
message SearchSugResponseData {
int32 forum_loc = 1;
repeated string list = 2;
repeated ForumInfo forum_list = 3;
RecommendForumInfo forum_card = 4;
Item item_card = 5;
repeated SugLiveInfo live_card = 6;
SugRankingInfo ranking_card = 7;
}

View File

@ -0,0 +1,12 @@
syntax = "proto3";
package tieba;
option java_package = "com.huanchengfly.tieba.post.api.models.protos";
import "AlaLiveInfo.proto";
message SugLiveInfo {
string word = 1;
AlaLiveInfo ala_info = 2;
}

View File

@ -0,0 +1,12 @@
syntax = "proto3";
package tieba;
option java_package = "com.huanchengfly.tieba.post.api.models.protos";
import "RankingParam.proto";
message SugRankingInfo {
string rank_title = 1;
RankingParam rank_param = 2;
}

View File

@ -741,4 +741,5 @@
<string name="summary_lift_up_bottom_bar">略微抬起贴子页面底栏以方便点按</string> <string name="summary_lift_up_bottom_bar">略微抬起贴子页面底栏以方便点按</string>
<string name="button_open">打开</string> <string name="button_open">打开</string>
<string name="btn_hide">隐藏</string> <string name="btn_hide">隐藏</string>
<string name="desc_search_sug">搜索联想:%s</string>
</resources> </resources>