From c9e779d004af9af5b6f950570414fdc14ff7db3d Mon Sep 17 00:00:00 2001 From: HuanCheng65 <22636177+HuanCheng65@users.noreply.github.com> Date: Sun, 28 Jan 2024 19:22:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E7=89=88=E5=90=A7=E5=86=85?= =?UTF-8?q?=E6=90=9C=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tieba/post/api/models/SearchThreadBean.kt | 86 +++- .../post/api/retrofit/RetrofitTiebaApi.kt | 1 + .../tieba/post/ui/common/PbContentRender.kt | 46 ++ .../tieba/post/ui/page/forum/ForumPage.kt | 29 +- .../forum/searchpost/ForumSearchPostPage.kt | 416 ++++++++++++++++++ .../searchpost/ForumSearchPostViewModel.kt | 221 ++++++++++ .../tieba/post/ui/widgets/compose/Menu.kt | 4 +- .../tieba/post/ui/widgets/compose/Search.kt | 103 ++++- .../tieba/post/utils/StringUtil.kt | 33 ++ 9 files changed, 918 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostPage.kt create mode 100644 app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostViewModel.kt diff --git a/app/src/main/java/com/huanchengfly/tieba/post/api/models/SearchThreadBean.kt b/app/src/main/java/com/huanchengfly/tieba/post/api/models/SearchThreadBean.kt index 1c5c47bc..fff8d078 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/api/models/SearchThreadBean.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/api/models/SearchThreadBean.kt @@ -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 = emptyList() + val postList: List = 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 = 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?, diff --git a/app/src/main/java/com/huanchengfly/tieba/post/api/retrofit/RetrofitTiebaApi.kt b/app/src/main/java/com/huanchengfly/tieba/post/api/retrofit/RetrofitTiebaApi.kt index 91915501..5a233c7d 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/api/retrofit/RetrofitTiebaApi.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/api/retrofit/RetrofitTiebaApi.kt @@ -339,6 +339,7 @@ object RetrofitTiebaApi { } private val json = Json { + isLenient = true ignoreUnknownKeys = true coerceInputValues = true } diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/common/PbContentRender.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/common/PbContentRender.kt index b632f3bd..81e53733 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/common/PbContentRender.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/common/PbContentRender.kt @@ -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, diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/ForumPage.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/ForumPage.kt index 9b3e17b3..bf064440 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/ForumPage.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/ForumPage.kt @@ -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 { - 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) { diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostPage.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostPage.kt new file mode 100644 index 00000000..f20a8d85 --- /dev/null +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostPage.kt @@ -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 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, + ) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostViewModel.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostViewModel.kt new file mode 100644 index 00000000..ed178292 --- /dev/null +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/page/forum/searchpost/ForumSearchPostViewModel.kt @@ -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() { + override fun createInitialState(): ForumSearchPostUiState = ForumSearchPostUiState() + + override fun createPartialChangeProducer(): PartialChangeProducer = + ForumSearchPostPartialChangeProducer + + private object ForumSearchPostPartialChangeProducer : + PartialChangeProducer { + @OptIn(ExperimentalCoroutinesApi::class) + override fun toPartialChangeFlow(intentFlow: Flow): Flow = + merge( + intentFlow.filterIsInstance() + .flatMapConcat { it.producePartialChange() }, + intentFlow.filterIsInstance() + .flatMapConcat { it.producePartialChange() }, + ) + + private fun ForumSearchPostUiIntent.Refresh.producePartialChange(): Flow = + TiebaApi.getInstance() + .searchPostFlow(keyword, forumName, forumId, sortType, filterType) + .map { + 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 = + TiebaApi.getInstance() + .searchPostFlow(keyword, forumName, forumId, sortType, filterType, page + 1) + .map { + 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 { + 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, + 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, + 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? = null, + val currentPage: Int = 1, + val hasMore: Boolean = true, + val keyword: String = "", + val data: ImmutableList = 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 +} \ No newline at end of file diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Menu.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Menu.kt index ea4e42f4..8875feae 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Menu.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Menu.kt @@ -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 diff --git a/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Search.kt b/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Search.kt index ee6ab649..d60f4304 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Search.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/ui/widgets/compose/Search.kt @@ -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, @@ -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), diff --git a/app/src/main/java/com/huanchengfly/tieba/post/utils/StringUtil.kt b/app/src/main/java/com/huanchengfly/tieba/post/utils/StringUtil.kt index 5573be65..cc749ba6 100644 --- a/app/src/main/java/com/huanchengfly/tieba/post/utils/StringUtil.kt +++ b/app/src/main/java/com/huanchengfly/tieba/post/utils/StringUtil.kt @@ -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 {