feat: 新版吧内搜索

This commit is contained in:
HuanCheng65 2024-01-28 19:22:56 +08:00
parent 71a3455b10
commit c9e779d004
No known key found for this signature in database
GPG Key ID: 5EC9DD60A32C7360
9 changed files with 918 additions and 21 deletions

View File

@ -1,58 +1,140 @@
package com.huanchengfly.tieba.post.api.models
import androidx.compose.runtime.Immutable
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Immutable
@Serializable
data class SearchThreadBean(
@SerialName("no")
@SerializedName("no")
val errorCode: Int,
@SerialName("error")
@SerializedName("error")
val errorMsg: String,
val data: DataBean
val data: DataBean,
) {
@Immutable
@Serializable
data class DataBean(
@SerialName("has_more")
@SerializedName("has_more")
val hasMore: Int,
@SerialName("current_page")
@SerializedName("current_page")
val currentPage: Int,
@SerialName("post_list")
@SerializedName("post_list")
val postList: List<ThreadInfoBean> = emptyList()
val postList: List<ThreadInfoBean> = emptyList(),
)
@Immutable
@Serializable
data class ThreadInfoBean(
val tid: String,
val pid: String,
val title: String,
val content: String,
val time: String,
@SerialName("modified_time")
@SerializedName("modified_time")
val modifiedTime: Long,
@SerialName("post_num")
@SerializedName("post_num")
val postNum: String,
@SerialName("like_num")
@SerializedName("like_num")
val likeNum: String,
@SerialName("share_num")
@SerializedName("share_num")
val shareNum: String,
@SerialName("forum_id")
@SerializedName("forum_id")
val forumId: String,
@SerialName("forum_name")
@SerializedName("forum_name")
val forumName: String,
val user: UserInfoBean,
val type: Int,
@SerialName("forum_info")
@SerializedName("forum_info")
val forumInfo: ForumInfo,
val media: List<MediaInfo> = emptyList(),
@SerialName("main_post")
@SerializedName("main_post")
val mainPost: MainPost? = null,
@SerialName("post_info")
@SerializedName("post_info")
val postInfo: PostInfo? = null,
)
@Immutable
@Serializable
data class MediaInfo(
val type: String,
val size: String,
val width: String,
val height: String,
@SerialName("water_pic")
@SerializedName("water_pic")
val waterPic: String,
@SerialName("small_pic")
@SerializedName("small_pic")
val smallPic: String,
@SerialName("big_pic")
@SerializedName("big_pic")
val bigPic: String,
)
@Immutable
@Serializable
data class MainPost(
val title: String,
val content: String,
val tid: Long,
val user: UserInfoBean,
@SerialName("like_num")
@SerializedName("like_num")
val likeNum: String,
@SerialName("share_num")
@SerializedName("share_num")
val shareNum: String,
@SerialName("post_num")
@SerializedName("post_num")
val postNum: String,
)
@Immutable
@Serializable
data class PostInfo(
val tid: Long,
val pid: Long,
val title: String,
val content: String,
val user: UserInfoBean,
)
@Immutable
@Serializable
data class ForumInfo(
@SerialName("forum_name")
@SerializedName("forum_name")
val forumName: String,
val avatar: String,
)
@Immutable
@Serializable
data class UserInfoBean(
@SerialName("user_name")
@SerializedName("user_name")
val userName: String?,
@SerialName("show_nickname")
@SerializedName("show_nickname")
val showNickname: String?,
@SerialName("user_id")
@SerializedName("user_id")
val userId: String,
val portrait: String?,

View File

@ -339,6 +339,7 @@ object RetrofitTiebaApi {
}
private val json = Json {
isLenient = true
ignoreUnknownKeys = true
coerceInputValues = true
}

View File

@ -222,6 +222,52 @@ data class VideoContentRender(
}
}
@Composable
fun PbContentText(
text: String,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
fontFamily: FontFamily? = null,
letterSpacing: TextUnit = TextUnit.Unspecified,
textDecoration: TextDecoration? = null,
textAlign: TextAlign? = null,
lineHeight: TextUnit = TextUnit.Unspecified,
lineSpacing: TextUnit = 0.sp,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1,
emoticonSize: Float = 0.9f,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current,
) {
PbContentText(
text = AnnotatedString(text),
modifier = modifier,
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
fontFamily = fontFamily,
letterSpacing = letterSpacing,
textDecoration = textDecoration,
textAlign = textAlign,
lineHeight = lineHeight,
lineSpacing = lineSpacing,
overflow = overflow,
softWrap = softWrap,
maxLines = maxLines,
minLines = minLines,
emoticonSize = emoticonSize,
inlineContent = emptyMap(),
onTextLayout = onTextLayout,
style = style
)
}
@Composable
fun PbContentText(
text: AnnotatedString,

View File

@ -40,7 +40,6 @@ import androidx.compose.material.Tab
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.ArrowBack
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Search
@ -83,7 +82,6 @@ import com.google.accompanist.placeholder.PlaceholderHighlight
import com.google.accompanist.placeholder.material.fade
import com.google.accompanist.placeholder.material.placeholder
import com.huanchengfly.tieba.post.R
import com.huanchengfly.tieba.post.activities.SearchPostActivity
import com.huanchengfly.tieba.post.api.models.protos.frsPage.ForumInfo
import com.huanchengfly.tieba.post.arch.ImmutableHolder
import com.huanchengfly.tieba.post.arch.collectPartialAsState
@ -93,13 +91,13 @@ import com.huanchengfly.tieba.post.arch.onEvent
import com.huanchengfly.tieba.post.arch.pageViewModel
import com.huanchengfly.tieba.post.dataStore
import com.huanchengfly.tieba.post.getInt
import com.huanchengfly.tieba.post.goToActivity
import com.huanchengfly.tieba.post.models.database.History
import com.huanchengfly.tieba.post.toastShort
import com.huanchengfly.tieba.post.ui.common.theme.compose.ExtendedTheme
import com.huanchengfly.tieba.post.ui.page.LocalNavigator
import com.huanchengfly.tieba.post.ui.page.ProvideNavigator
import com.huanchengfly.tieba.post.ui.page.destinations.ForumDetailPageDestination
import com.huanchengfly.tieba.post.ui.page.destinations.ForumSearchPostPageDestination
import com.huanchengfly.tieba.post.ui.page.forum.threadlist.ForumThreadListPage
import com.huanchengfly.tieba.post.ui.page.forum.threadlist.ForumThreadListUiEvent
import com.huanchengfly.tieba.post.ui.widgets.compose.Avatar
@ -579,7 +577,8 @@ fun ForumPage(
) {
Text(text = stringResource(id = R.string.title_unfollow))
}
}
},
forumId = forumInfo?.get { id }
)
},
floatingActionButton = {
@ -957,7 +956,7 @@ private fun BackNavigationIconPlaceholder() {
modifier = Modifier.alpha(0f)
) {
Icon(
imageVector = Icons.Rounded.ArrowBack,
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
@ -968,9 +967,9 @@ private fun ForumToolbar(
forumName: String,
showTitle: Boolean,
menuContent: @Composable (MenuScope.() -> Unit)? = null,
forumId: Long? = null,
) {
val navigator = LocalNavigator.current
val context = LocalContext.current
Toolbar(
title = {
if (showTitle) Text(
@ -982,17 +981,17 @@ private fun ForumToolbar(
},
navigationIcon = { BackNavigationIcon(onBackPressed = { navigator.navigateUp() }) },
actions = {
IconButton(
onClick = {
context.goToActivity<SearchPostActivity> {
putExtra(SearchPostActivity.PARAM_FORUM, forumName)
if (forumId != null) {
IconButton(
onClick = {
navigator.navigate(ForumSearchPostPageDestination(forumName, forumId))
}
) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = stringResource(id = R.string.btn_search_in_forum)
)
}
) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = stringResource(id = R.string.btn_search_in_forum)
)
}
Box {
if (menuContent != null) {

View File

@ -0,0 +1,416 @@
package com.huanchengfly.tieba.post.ui.page.forum.searchpost
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ContentAlpha
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.ArrowDropDown
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.huanchengfly.tieba.post.R
import com.huanchengfly.tieba.post.arch.collectPartialAsState
import com.huanchengfly.tieba.post.arch.pageViewModel
import com.huanchengfly.tieba.post.ui.common.theme.compose.ExtendedTheme
import com.huanchengfly.tieba.post.ui.common.theme.compose.pullRefreshIndicator
import com.huanchengfly.tieba.post.ui.page.ProvideNavigator
import com.huanchengfly.tieba.post.ui.page.destinations.ForumPageDestination
import com.huanchengfly.tieba.post.ui.page.destinations.ThreadPageDestination
import com.huanchengfly.tieba.post.ui.page.destinations.UserProfilePageDestination
import com.huanchengfly.tieba.post.ui.widgets.compose.ClickMenu
import com.huanchengfly.tieba.post.ui.widgets.compose.ErrorScreen
import com.huanchengfly.tieba.post.ui.widgets.compose.HorizontalDivider
import com.huanchengfly.tieba.post.ui.widgets.compose.LoadMoreLayout
import com.huanchengfly.tieba.post.ui.widgets.compose.MyScaffold
import com.huanchengfly.tieba.post.ui.widgets.compose.SearchBox
import com.huanchengfly.tieba.post.ui.widgets.compose.SearchThreadList
import com.huanchengfly.tieba.post.ui.widgets.compose.TopAppBarContainer
import com.huanchengfly.tieba.post.ui.widgets.compose.picker.ListSinglePicker
import com.huanchengfly.tieba.post.ui.widgets.compose.rememberMenuState
import com.huanchengfly.tieba.post.ui.widgets.compose.states.StateScreen
import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
@Destination
@Composable
fun ForumSearchPostPage(
forumName: String,
forumId: Long,
navigator: DestinationsNavigator,
viewModel: ForumSearchPostViewModel = pageViewModel(),
) {
val context = LocalContext.current
val currentKeyword by viewModel.uiState.collectPartialAsState(
prop1 = ForumSearchPostUiState::keyword,
initial = ""
)
val error by viewModel.uiState.collectPartialAsState(
prop1 = ForumSearchPostUiState::error,
initial = null
)
val data by viewModel.uiState.collectPartialAsState(
prop1 = ForumSearchPostUiState::data,
initial = persistentListOf()
)
val isRefreshing by viewModel.uiState.collectPartialAsState(
prop1 = ForumSearchPostUiState::isRefreshing,
initial = true
)
val isLoadingMore by viewModel.uiState.collectPartialAsState(
prop1 = ForumSearchPostUiState::isLoadingMore,
initial = false
)
val currentSortType by viewModel.uiState.collectPartialAsState(
prop1 = ForumSearchPostUiState::sortType,
initial = ForumSearchPostSortType.NEWEST
)
val currentFilterType by viewModel.uiState.collectPartialAsState(
prop1 = ForumSearchPostUiState::filterType,
initial = ForumSearchPostFilterType.ALL
)
val currentPage by viewModel.uiState.collectPartialAsState(
prop1 = ForumSearchPostUiState::currentPage,
initial = 1
)
val hasMore by viewModel.uiState.collectPartialAsState(
prop1 = ForumSearchPostUiState::hasMore,
initial = true
)
val isEmpty by remember {
derivedStateOf { data.isEmpty() }
}
val isError by remember {
derivedStateOf { error != null }
}
val isKeywordEmpty by remember {
derivedStateOf { currentKeyword.isEmpty() }
}
var inputKeyword by remember { mutableStateOf("") }
fun refresh() {
viewModel.send(
ForumSearchPostUiIntent.Refresh(
currentKeyword,
forumName,
forumId,
currentSortType,
currentFilterType
)
)
}
val pullRefreshState = rememberPullRefreshState(
refreshing = isRefreshing,
onRefresh = ::refresh
)
val lazyListState = rememberLazyListState()
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val sortTypeMapping = remember {
mapOf(
ForumSearchPostSortType.NEWEST to context.getString(R.string.title_search_post_sort_by_time),
ForumSearchPostSortType.RELATIVE to context.getString(R.string.title_search_post_sort_by_relevant),
)
}
val filterTypeMapping = remember {
mapOf(
ForumSearchPostFilterType.ALL to context.getString(R.string.title_search_filter_all),
ForumSearchPostFilterType.ONLY_THREAD to context.getString(R.string.title_search_filter_only_thread),
)
}
MyScaffold(
topBar = {
TopAppBarContainer(
topBar = {
Box(
modifier = Modifier
.height(64.dp)
.background(ExtendedTheme.colors.topBar)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
SearchBox(
keyword = inputKeyword,
onKeywordChange = { inputKeyword = it },
modifier = Modifier.fillMaxSize(),
onKeywordSubmit = {
focusRequester.freeFocus()
keyboardController?.hide()
viewModel.send(
ForumSearchPostUiIntent.Refresh(
it,
forumName,
forumId,
currentSortType,
currentFilterType
)
)
},
placeholder = {
Text(
text = stringResource(
id = R.string.hint_search_in_ba,
forumName
),
color = ExtendedTheme.colors.onTopBarSurface.copy(alpha = ContentAlpha.medium)
)
},
prependIcon = {
Box(
modifier = Modifier
.clip(RoundedCornerShape(100))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, 24.dp),
role = Role.Button,
onClick = { navigator.navigateUp() }
),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(id = R.string.button_back)
)
}
},
focusRequester = focusRequester,
shape = RoundedCornerShape(6.dp)
)
}
}
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
ProvideNavigator(navigator = navigator) {
if (!isKeywordEmpty) {
StateScreen(
modifier = Modifier.fillMaxSize(),
isEmpty = isEmpty,
isError = isError,
isLoading = isRefreshing,
onReload = ::refresh,
errorScreen = {
error?.item?.let {
ErrorScreen(error = it)
}
}
) {
Box(
modifier = Modifier
.fillMaxSize()
.pullRefresh(pullRefreshState)
) {
LoadMoreLayout(
isLoading = isLoadingMore,
onLoadMore = {
viewModel.send(
ForumSearchPostUiIntent.LoadMore(
currentKeyword,
forumName,
forumId,
currentPage,
currentSortType,
currentFilterType
)
)
},
loadEnd = !hasMore,
lazyListState = lazyListState,
) {
SearchThreadList(
data = data,
lazyListState = lazyListState,
onItemClick = {
navigator.navigate(
ThreadPageDestination(
threadId = it.tid.toLong()
)
)
},
onItemUserClick = {
navigator.navigate(UserProfilePageDestination(it.userId.toLong()))
},
onItemForumClick = {
navigator.navigate(
ForumPageDestination(
it.forumName
)
)
},
hideForum = true,
) {
stickyHeader(key = "Sort&Filter") {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.background(ExtendedTheme.colors.background)
.padding(horizontal = 16.dp, vertical = 8.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = {}
)
) {
val menuState = rememberMenuState()
val rotate by animateFloatAsState(
targetValue = if (menuState.expanded) 180f else 0f,
label = "ArrowIndicatorRotate"
)
ClickMenu(
menuContent = {
ListSinglePicker(
itemTitles = sortTypeMapping.values.toImmutableList(),
itemValues = sortTypeMapping.keys.toImmutableList(),
selectedPosition = sortTypeMapping.keys.indexOf(
currentSortType
),
onItemSelected = { _, _, newSortType, changed ->
if (changed) {
viewModel.send(
ForumSearchPostUiIntent.Refresh(
currentKeyword,
forumName,
forumId,
newSortType,
currentFilterType
)
)
}
dismiss()
}
)
},
menuState = menuState,
indication = null
) {
Row(
verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = sortTypeMapping[currentSortType]
?: "",
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
Icon(
imageVector = Icons.Rounded.ArrowDropDown,
contentDescription = null,
modifier = Modifier
.size(16.dp)
.rotate(rotate)
)
}
}
Spacer(modifier = Modifier.weight(1f))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(IntrinsicSize.Min)
) {
filterTypeMapping.keys.map<Int, @Composable () -> Unit> { type ->
{
Text(
text = filterTypeMapping[type] ?: "",
fontSize = 13.sp,
fontWeight = if (type == currentFilterType) {
FontWeight.Bold
} else {
FontWeight.Normal
},
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
role = Role.RadioButton,
onClick = {
if (type != currentFilterType) {
viewModel.send(
ForumSearchPostUiIntent.Refresh(
currentKeyword,
forumName,
forumId,
currentSortType,
type
)
)
}
}
)
)
}
}.forEachIndexed { index, composable ->
composable()
if (index < filterTypeMapping.size - 1) {
HorizontalDivider(
modifier = Modifier.padding(
horizontal = 8.dp
)
)
}
}
}
}
}
}
}
PullRefreshIndicator(
refreshing = isRefreshing,
state = pullRefreshState,
modifier = Modifier.align(Alignment.TopCenter),
backgroundColor = ExtendedTheme.colors.pullRefreshIndicator,
contentColor = ExtendedTheme.colors.primary,
)
}
}
}
}
}
}
}

View File

@ -0,0 +1,221 @@
package com.huanchengfly.tieba.post.ui.page.forum.searchpost
import com.huanchengfly.tieba.post.api.TiebaApi
import com.huanchengfly.tieba.post.api.models.SearchThreadBean
import com.huanchengfly.tieba.post.arch.BaseViewModel
import com.huanchengfly.tieba.post.arch.ImmutableHolder
import com.huanchengfly.tieba.post.arch.PartialChange
import com.huanchengfly.tieba.post.arch.PartialChangeProducer
import com.huanchengfly.tieba.post.arch.UiEvent
import com.huanchengfly.tieba.post.arch.UiIntent
import com.huanchengfly.tieba.post.arch.UiState
import com.huanchengfly.tieba.post.arch.wrapImmutable
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onStart
import javax.inject.Inject
@HiltViewModel
class ForumSearchPostViewModel @Inject constructor() :
BaseViewModel<ForumSearchPostUiIntent, ForumSearchPostPartialChange, ForumSearchPostUiState, UiEvent>() {
override fun createInitialState(): ForumSearchPostUiState = ForumSearchPostUiState()
override fun createPartialChangeProducer(): PartialChangeProducer<ForumSearchPostUiIntent, ForumSearchPostPartialChange, ForumSearchPostUiState> =
ForumSearchPostPartialChangeProducer
private object ForumSearchPostPartialChangeProducer :
PartialChangeProducer<ForumSearchPostUiIntent, ForumSearchPostPartialChange, ForumSearchPostUiState> {
@OptIn(ExperimentalCoroutinesApi::class)
override fun toPartialChangeFlow(intentFlow: Flow<ForumSearchPostUiIntent>): Flow<ForumSearchPostPartialChange> =
merge(
intentFlow.filterIsInstance<ForumSearchPostUiIntent.Refresh>()
.flatMapConcat { it.producePartialChange() },
intentFlow.filterIsInstance<ForumSearchPostUiIntent.LoadMore>()
.flatMapConcat { it.producePartialChange() },
)
private fun ForumSearchPostUiIntent.Refresh.producePartialChange(): Flow<ForumSearchPostPartialChange.Refresh> =
TiebaApi.getInstance()
.searchPostFlow(keyword, forumName, forumId, sortType, filterType)
.map<SearchThreadBean, ForumSearchPostPartialChange.Refresh> {
val postList = it.data.postList.toImmutableList()
ForumSearchPostPartialChange.Refresh.Success(
keyword = keyword,
data = postList,
hasMore = it.data.hasMore == 1,
sortType = sortType,
filterType = filterType,
)
}
.onStart {
emit(
ForumSearchPostPartialChange.Refresh.Start(
keyword,
sortType,
filterType
)
)
}
.catch { emit(ForumSearchPostPartialChange.Refresh.Failure(it)) }
private fun ForumSearchPostUiIntent.LoadMore.producePartialChange(): Flow<ForumSearchPostPartialChange.LoadMore> =
TiebaApi.getInstance()
.searchPostFlow(keyword, forumName, forumId, sortType, filterType, page + 1)
.map<SearchThreadBean, ForumSearchPostPartialChange.LoadMore> {
val postList = it.data.postList.toImmutableList()
ForumSearchPostPartialChange.LoadMore.Success(
keyword = keyword,
data = postList,
hasMore = it.data.hasMore == 1,
page = page + 1,
sortType = sortType,
filterType = filterType,
)
}
.onStart { emit(ForumSearchPostPartialChange.LoadMore.Start) }
.catch { emit(ForumSearchPostPartialChange.LoadMore.Failure(it)) }
}
}
sealed interface ForumSearchPostUiIntent : UiIntent {
data class Refresh(
val keyword: String,
val forumName: String,
val forumId: Long,
val sortType: Int,
val filterType: Int,
) : ForumSearchPostUiIntent
data class LoadMore(
val keyword: String,
val forumName: String,
val forumId: Long,
val page: Int,
val sortType: Int,
val filterType: Int,
) : ForumSearchPostUiIntent
}
sealed interface ForumSearchPostPartialChange : PartialChange<ForumSearchPostUiState> {
sealed class Refresh : ForumSearchPostPartialChange {
override fun reduce(oldState: ForumSearchPostUiState): ForumSearchPostUiState =
when (this) {
is Start -> oldState.copy(
isRefreshing = true,
isLoadingMore = false,
error = null,
keyword = keyword,
sortType = sortType,
filterType = filterType,
)
is Success -> oldState.copy(
isRefreshing = false,
isLoadingMore = false,
error = null,
currentPage = 1,
hasMore = hasMore,
keyword = keyword,
data = data,
sortType = sortType,
filterType = filterType,
)
is Failure -> oldState.copy(
isRefreshing = false,
isLoadingMore = false,
error = error.wrapImmutable()
)
}
data class Start(
val keyword: String,
val sortType: Int,
val filterType: Int,
) : Refresh()
data class Success(
val keyword: String,
val data: ImmutableList<SearchThreadBean.ThreadInfoBean>,
val hasMore: Boolean,
val sortType: Int,
val filterType: Int,
) : Refresh()
data class Failure(
val error: Throwable,
) : Refresh()
}
sealed class LoadMore : ForumSearchPostPartialChange {
override fun reduce(oldState: ForumSearchPostUiState): ForumSearchPostUiState =
when (this) {
is Start -> oldState.copy(
isRefreshing = false,
isLoadingMore = true,
error = null,
)
is Success -> oldState.copy(
isRefreshing = false,
isLoadingMore = false,
error = null,
currentPage = page,
hasMore = hasMore,
data = (oldState.data + data).toImmutableList(),
)
is Failure -> oldState.copy(
isRefreshing = false,
isLoadingMore = false,
error = error.wrapImmutable()
)
}
data object Start : LoadMore()
data class Success(
val keyword: String,
val data: ImmutableList<SearchThreadBean.ThreadInfoBean>,
val hasMore: Boolean,
val page: Int,
val sortType: Int,
val filterType: Int,
) : LoadMore()
data class Failure(
val error: Throwable,
) : LoadMore()
}
}
data class ForumSearchPostUiState(
val isRefreshing: Boolean = true,
val isLoadingMore: Boolean = false,
val error: ImmutableHolder<Throwable>? = null,
val currentPage: Int = 1,
val hasMore: Boolean = true,
val keyword: String = "",
val data: ImmutableList<SearchThreadBean.ThreadInfoBean> = persistentListOf(),
val sortType: Int = ForumSearchPostSortType.NEWEST,
val filterType: Int = ForumSearchPostFilterType.ALL,
) : UiState
object ForumSearchPostSortType {
const val NEWEST = 1
const val RELATIVE = 2
}
object ForumSearchPostFilterType {
const val ONLY_THREAD = 1
const val ALL = 2
}

View File

@ -52,13 +52,13 @@ fun ClickMenu(
modifier: Modifier = Modifier,
menuState: MenuState = rememberMenuState(),
menuShape: Shape = RoundedCornerShape(14.dp),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
indication: Indication? = LocalIndication.current,
triggerShape: Shape? = null,
onDismiss: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
val menuScope = MenuScope(menuState, onDismiss)
val interactionSource = remember { MutableInteractionSource() }
val indication = LocalIndication.current
LaunchedEffect(key1 = null) {
launch {
interactionSource.interactions

View File

@ -1,10 +1,12 @@
package com.huanchengfly.tieba.post.ui.widgets.compose
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
@ -17,6 +19,7 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.Icon
import androidx.compose.material.LocalContentColor
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
@ -40,16 +43,92 @@ import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.huanchengfly.tieba.post.R
import com.huanchengfly.tieba.post.api.models.SearchThreadBean
import com.huanchengfly.tieba.post.ui.common.PbContentText
import com.huanchengfly.tieba.post.ui.common.theme.compose.ExtendedTheme
import com.huanchengfly.tieba.post.utils.DateTimeUtils
import com.huanchengfly.tieba.post.utils.StringUtil
import com.huanchengfly.tieba.post.utils.StringUtil.buildAnnotatedStringWithUser
import kotlinx.collections.immutable.ImmutableList
@Composable
fun QuotePostCard(
quotePostInfo: SearchThreadBean.PostInfo,
mainPost: SearchThreadBean.MainPost,
modifier: Modifier = Modifier,
) {
val quoteContentString = remember(quotePostInfo) {
buildAnnotatedStringWithUser(
quotePostInfo.user.userId,
quotePostInfo.user.userName ?: "",
quotePostInfo.user.showNickname,
quotePostInfo.content
)
}
Column(
modifier = modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
PbContentText(
text = quoteContentString,
style = MaterialTheme.typography.body2,
modifier = modifier,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
MainPostCard(
mainPost = mainPost,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.background(ExtendedTheme.colors.card)
)
}
}
@Composable
fun MainPostCard(
mainPost: SearchThreadBean.MainPost,
modifier: Modifier = Modifier,
) {
val titleString = remember(mainPost) {
buildAnnotatedStringWithUser(
mainPost.user.userId,
mainPost.user.userName ?: "",
mainPost.user.showNickname,
mainPost.title
)
}
Column(
modifier = modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
PbContentText(
text = titleString,
style = MaterialTheme.typography.subtitle2,
fontWeight = FontWeight.Bold,
modifier = modifier,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (mainPost.content.isNotBlank()) {
PbContentText(
text = mainPost.content,
style = MaterialTheme.typography.body2,
modifier = modifier,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
fun SearchThreadList(
data: ImmutableList<SearchThreadBean.ThreadInfoBean>,
@ -138,9 +217,29 @@ fun SearchThreadItem(
ThreadContent(
title = item.title,
abstractText = item.content,
showTitle = item.title.isNotBlank(),
showTitle = item.mainPost == null && item.title.isNotBlank(),
showAbstract = item.content.isNotBlank(),
)
if (item.mainPost != null) {
if (item.postInfo != null) {
QuotePostCard(
quotePostInfo = item.postInfo,
mainPost = item.mainPost,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.background(ExtendedTheme.colors.floorCard)
)
} else {
MainPostCard(
mainPost = item.mainPost,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(6.dp))
.background(ExtendedTheme.colors.floorCard)
)
}
}
if (!hideForum && item.forumName.isNotEmpty()) {
ForumInfoChip(
imageUriProvider = { item.forumInfo.avatar },
@ -198,7 +297,7 @@ fun SearchBox(
shape = shape,
color = color,
contentColor = contentColor,
elevation = 0.dp
elevation = elevation
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),

View File

@ -11,8 +11,10 @@ import android.widget.TextView
import androidx.compose.runtime.Stable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withAnnotation
import androidx.compose.ui.text.withStyle
import com.huanchengfly.tieba.post.App
import com.huanchengfly.tieba.post.R
@ -112,6 +114,37 @@ object StringUtil {
}
}
@OptIn(ExperimentalTextApi::class)
@Stable
fun buildAnnotatedStringWithUser(
userId: String,
username: String,
nickname: String?,
content: String,
context: Context = App.INSTANCE,
): AnnotatedString {
return buildAnnotatedString {
withAnnotation(tag = "user", annotation = userId) {
withStyle(
SpanStyle(
color = Color(ThemeUtils.getColorByAttr(context, R.attr.colorNewPrimary))
)
) {
append("@")
append(
getUsernameAnnotatedString(
context,
username,
nickname,
)
)
}
}
append(": ")
append(content)
}
}
@JvmStatic
@Stable
fun getAvatarUrl(portrait: String?): String {