feat: 新版吧内搜索
This commit is contained in:
parent
71a3455b10
commit
c9e779d004
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -339,6 +339,7 @@ object RetrofitTiebaApi {
|
|||
}
|
||||
|
||||
private val json = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue