diff --git a/app/src/main/java/com/huanchengfly/tieba/post/api/interfaces/ITiebaApi.kt b/app/src/main/java/com/huanchengfly/tieba/post/api/interfaces/ITiebaApi.kt index e7427bca..09afb552 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/api/interfaces/ITiebaApi.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/api/interfaces/ITiebaApi.kt @@ -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.personalized.PersonalizedResponse 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.topicList.TopicListResponse import com.huanchengfly.tieba.post.api.models.protos.userLike.UserLikeResponse @@ -1378,6 +1379,17 @@ interface ITiebaApi { postId: Long, forumId: Long = 0L, page: Int = 1, - subPostId: Long = 0L + subPostId: Long = 0L, ): Flow + + /** + * 搜索联想 + * + * @param keyword 关键词 + * @param isForum 是否为吧 + */ + fun searchSuggestionsFlow( + keyword: String, + isForum: Boolean = false, + ): Flow } \ No newline at end of file diff --git a/app/src/main/java/com/huanchengfly/tieba/post/api/interfaces/impls/MixedTiebaApiImpl.kt b/app/src/main/java/com/huanchengfly/tieba/post/api/interfaces/impls/MixedTiebaApiImpl.kt index ecdaa183..0145ccde 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/api/interfaces/impls/MixedTiebaApiImpl.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/api/interfaces/impls/MixedTiebaApiImpl.kt @@ -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.ProfileRequestData 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.ThreadListRequest 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 { + 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 + ) + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/huanchengfly/tieba/post/api/retrofit/interfaces/OfficialProtobufTiebaApi.kt b/app/src/main/java/com/huanchengfly/tieba/post/api/retrofit/interfaces/OfficialProtobufTiebaApi.kt index b8f20ec7..784629f7 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/api/retrofit/interfaces/OfficialProtobufTiebaApi.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/api/retrofit/interfaces/OfficialProtobufTiebaApi.kt @@ -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.personalized.PersonalizedResponse 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.topicList.TopicListResponse import com.huanchengfly.tieba.post.api.models.protos.userLike.UserLikeResponse @@ -73,4 +74,9 @@ interface OfficialProtobufTiebaApi { fun addPostFlow( @Body body: MyMultipartBody, ): Flow + + @POST("/c/s/searchSug?cmd=309438&format=protobuf") + fun searchSugFlow( + @Body body: MyMultipartBody, + ): Flow } \ No newline at end of file diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchPage.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchPage.kt index 9a776d36..0ee2b7b0 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchPage.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchPage.kt @@ -23,6 +23,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.rememberPagerState import androidx.compose.foundation.rememberScrollState @@ -57,6 +59,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.Button 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.MyBackHandler 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.persistentMapOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.launch @@ -121,7 +127,7 @@ data class SearchPageItem( val onSelectedSortTypeChange: (Int) -> Unit = {}, ) -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, FlowPreview::class) @Destination @Composable fun SearchPage( @@ -144,7 +150,25 @@ fun SearchPage( prop1 = SearchUiState::isKeywordEmpty, 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("") } + + LaunchedEffect(Unit) { + snapshotFlow { inputKeyword } + .debounce(500) + .collect { + viewModel.send(SearchUiIntent.KeywordInputChanged(it)) + } + } + LaunchedEffect(keyword) { if (keyword.isNotEmpty() && keyword != inputKeyword) { inputKeyword = keyword @@ -265,21 +289,76 @@ fun SearchPage( } } } else { - Column( + if (showSuggestions) { + SearchSuggestionList( + suggestions = suggestions, + onItemClick = { + inputKeyword = it + viewModel.send(SearchUiIntent.SubmitKeyword(it)) + } + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Container { + SearchHistoryList( + searchHistories = searchHistories, + onSearchHistoryClick = { + inputKeyword = it.content + viewModel.send(SearchUiIntent.SubmitKeyword(it.content)) + }, + expanded = expanded, + onToggleExpand = { expanded = !expanded }, + onDelete = { viewModel.send(SearchUiIntent.DeleteSearchHistory(it.id)) }, + onClear = { viewModel.send(SearchUiIntent.ClearSearchHistory) } + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun SearchSuggestionList( + suggestions: ImmutableList, + 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 - .fillMaxSize() - .verticalScroll(rememberScrollState()) + .clickable { + onItemClick(it) + } + .padding(horizontal = 16.dp, vertical = 8.dp) ) { - SearchHistoryList( - searchHistories = searchHistories, - onSearchHistoryClick = { - inputKeyword = it.content - viewModel.send(SearchUiIntent.SubmitKeyword(it.content)) - }, - expanded = expanded, - onToggleExpand = { expanded = !expanded }, - onDelete = { viewModel.send(SearchUiIntent.DeleteSearchHistory(it.id)) }, - onClear = { viewModel.send(SearchUiIntent.ClearSearchHistory) } + 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 ) } } @@ -287,6 +366,21 @@ fun SearchPage( } } +@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) @Composable private fun ColumnScope.SearchTabRow( diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchViewModel.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchViewModel.kt index 4d6e50ac..9ca31d91 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchViewModel.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/search/SearchViewModel.kt @@ -2,6 +2,8 @@ package com.huanchengfly.tieba.post.ui.page.search import com.huanchengfly.tieba.post.App 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.getErrorMessage 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.flowOf import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import org.litepal.LitePal @@ -70,6 +73,8 @@ class SearchViewModel : .flatMapConcat { it.producePartialChange() }, intentFlow.filterIsInstance() .flatMapConcat { it.producePartialChange() }, + intentFlow.filterIsInstance() + .flatMapConcat { it.producePartialChange() }, ) private fun produceInitPartialChange() = flow { @@ -109,6 +114,21 @@ class SearchViewModel : ) } } + + private fun SearchUiIntent.KeywordInputChanged.producePartialChange() = + if (keyword.isNotBlank()) { + TiebaApi.getInstance().searchSuggestionsFlow(keyword) + .map { + 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 SubmitKeyword(val keyword: String) : SearchUiIntent + + data class KeywordInputChanged(val keyword: String) : SearchUiIntent } sealed interface SearchPartialChange : PartialChange { @@ -167,7 +189,10 @@ sealed interface SearchPartialChange : PartialChange { data class SubmitKeyword(val keyword: String) : SearchPartialChange { override fun reduce(oldState: SearchUiState): SearchUiState { if (keyword.isEmpty()) { - return oldState.copy(isKeywordEmpty = true) + return oldState.copy( + isKeywordEmpty = true, + suggestions = persistentListOf() + ) } val newSearchHistories = (oldState.searchHistories .filterNot { it.content == keyword } + SearchHistory(content = keyword)) @@ -179,12 +204,26 @@ sealed interface SearchPartialChange : PartialChange { ) } } + + 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) : KeywordInputChanged() + + data class Failure( + val errorMessage: String, + ) : KeywordInputChanged() + } } data class SearchUiState( val keyword: String = "", val isKeywordEmpty: Boolean = true, val searchHistories: ImmutableList = persistentListOf(), + val suggestions: ImmutableList = persistentListOf(), ) : UiState sealed interface SearchUiEvent : UiEvent { diff --git a/app/src/main/protos/ForumInfo.proto b/app/src/main/protos/ForumInfo.proto new file mode 100644 index 00000000..03647d05 --- /dev/null +++ b/app/src/main/protos/ForumInfo.proto @@ -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; +} diff --git a/app/src/main/protos/RankingParam.proto b/app/src/main/protos/RankingParam.proto new file mode 100644 index 00000000..5ce39d26 --- /dev/null +++ b/app/src/main/protos/RankingParam.proto @@ -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; +} diff --git a/app/src/main/protos/RecommendForumInfo.proto b/app/src/main/protos/RecommendForumInfo.proto new file mode 100644 index 00000000..f5caa2d3 --- /dev/null +++ b/app/src/main/protos/RecommendForumInfo.proto @@ -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; +} diff --git a/app/src/main/protos/SearchSug/SearchSugRequest.proto b/app/src/main/protos/SearchSug/SearchSugRequest.proto new file mode 100644 index 00000000..3afe975b --- /dev/null +++ b/app/src/main/protos/SearchSug/SearchSugRequest.proto @@ -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; +} diff --git a/app/src/main/protos/SearchSug/SearchSugRequestData.proto b/app/src/main/protos/SearchSug/SearchSugRequestData.proto new file mode 100644 index 00000000..e6edb63a --- /dev/null +++ b/app/src/main/protos/SearchSug/SearchSugRequestData.proto @@ -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; +} diff --git a/app/src/main/protos/SearchSug/SearchSugResponse.proto b/app/src/main/protos/SearchSug/SearchSugResponse.proto new file mode 100644 index 00000000..0a51b4e8 --- /dev/null +++ b/app/src/main/protos/SearchSug/SearchSugResponse.proto @@ -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; +} diff --git a/app/src/main/protos/SearchSug/SearchSugResponseData.proto b/app/src/main/protos/SearchSug/SearchSugResponseData.proto new file mode 100644 index 00000000..99634ed0 --- /dev/null +++ b/app/src/main/protos/SearchSug/SearchSugResponseData.proto @@ -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; +} diff --git a/app/src/main/protos/SugLiveInfo.proto b/app/src/main/protos/SugLiveInfo.proto new file mode 100644 index 00000000..8370772e --- /dev/null +++ b/app/src/main/protos/SugLiveInfo.proto @@ -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; +} diff --git a/app/src/main/protos/SugRankingInfo.proto b/app/src/main/protos/SugRankingInfo.proto new file mode 100644 index 00000000..9f0cf2b7 --- /dev/null +++ b/app/src/main/protos/SugRankingInfo.proto @@ -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; +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3f6a6abc..4502bae4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -741,4 +741,5 @@ 略微抬起贴子页面底栏以方便点按 打开 隐藏 + 搜索联想:%s