feat: 热榜页面贴子卡片 & 点赞

This commit is contained in:
HuanCheng65 2023-07-18 22:19:25 +08:00
parent dff92beadc
commit a5c32056c0
No known key found for this signature in database
GPG Key ID: 5EC9DD60A32C7360
5 changed files with 228 additions and 29 deletions

View File

@ -57,6 +57,12 @@ val ThreadInfo.abstractText: String
} }
} }
val ThreadInfo.hasAgree: Int
get() = agree?.hasAgree ?: 0
val ThreadInfo.hasAgreed: Boolean
get() = hasAgree == 1
val ThreadInfo.hasAbstract: Boolean val ThreadInfo.hasAbstract: Boolean
get() = richAbstract.any { (it.type == 0 && it.text.isNotBlank()) || it.type == 2 } get() = richAbstract.any { (it.type == 0 && it.text.isNotBlank()) || it.type == 2 }

View File

@ -31,6 +31,7 @@ import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -45,7 +46,9 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.google.accompanist.placeholder.material.placeholder import com.google.accompanist.placeholder.material.placeholder
import com.huanchengfly.tieba.post.R import com.huanchengfly.tieba.post.R
import com.huanchengfly.tieba.post.api.hasAgree
import com.huanchengfly.tieba.post.api.models.protos.ThreadInfo import com.huanchengfly.tieba.post.api.models.protos.ThreadInfo
import com.huanchengfly.tieba.post.arch.ImmutableHolder
import com.huanchengfly.tieba.post.arch.collectPartialAsState import com.huanchengfly.tieba.post.arch.collectPartialAsState
import com.huanchengfly.tieba.post.arch.onEvent import com.huanchengfly.tieba.post.arch.onEvent
import com.huanchengfly.tieba.post.arch.pageViewModel import com.huanchengfly.tieba.post.arch.pageViewModel
@ -56,7 +59,9 @@ import com.huanchengfly.tieba.post.ui.common.theme.compose.White
import com.huanchengfly.tieba.post.ui.common.theme.compose.Yellow import com.huanchengfly.tieba.post.ui.common.theme.compose.Yellow
import com.huanchengfly.tieba.post.ui.page.LocalNavigator import com.huanchengfly.tieba.post.ui.page.LocalNavigator
import com.huanchengfly.tieba.post.ui.page.destinations.HotTopicListPageDestination import com.huanchengfly.tieba.post.ui.page.destinations.HotTopicListPageDestination
import com.huanchengfly.tieba.post.ui.page.destinations.ThreadPageDestination
import com.huanchengfly.tieba.post.ui.page.main.MainUiEvent import com.huanchengfly.tieba.post.ui.page.main.MainUiEvent
import com.huanchengfly.tieba.post.ui.widgets.compose.FeedCard
import com.huanchengfly.tieba.post.ui.widgets.compose.LazyLoad import com.huanchengfly.tieba.post.ui.widgets.compose.LazyLoad
import com.huanchengfly.tieba.post.ui.widgets.compose.NetworkImage import com.huanchengfly.tieba.post.ui.widgets.compose.NetworkImage
import com.huanchengfly.tieba.post.ui.widgets.compose.ProvideContentColor import com.huanchengfly.tieba.post.ui.widgets.compose.ProvideContentColor
@ -155,12 +160,12 @@ fun HotPage(
modifier = Modifier.padding(bottom = 2.dp) modifier = Modifier.padding(bottom = 2.dp)
) )
Text( Text(
text = item.topicName, text = item.get { topicName },
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
when (item.tag) { when (item.get { tag }) {
2 -> Text( 2 -> Text(
text = stringResource(id = R.string.topic_tag_hot), text = stringResource(id = R.string.topic_tag_hot),
fontSize = 10.sp, fontSize = 10.sp,
@ -220,7 +225,7 @@ fun HotPage(
} }
if (threadList.isNotEmpty()) { if (threadList.isNotEmpty()) {
if (tabList.isNotEmpty()) { if (tabList.isNotEmpty()) {
stickyHeader(key = "ThreadTabs") { item(key = "ThreadTabs") {
VerticalGrid( VerticalGrid(
column = 5, column = 5,
modifier = Modifier modifier = Modifier
@ -239,9 +244,9 @@ fun HotPage(
} }
items(tabList) { items(tabList) {
ThreadListTab( ThreadListTab(
text = it.tabName, text = it.get { tabName },
selected = currentTabCode == it.tabCode, selected = currentTabCode == it.get { tabCode },
onSelected = { viewModel.send(HotUiIntent.RefreshThreadList(it.tabCode)) } onSelected = { viewModel.send(HotUiIntent.RefreshThreadList(it.get { tabCode })) }
) )
} }
} }
@ -266,8 +271,68 @@ fun HotPage(
} else { } else {
itemsIndexed( itemsIndexed(
items = threadList, items = threadList,
key = { _, item -> "Thread_${item.threadId}" }) { index, item -> key = { _, item -> "Thread_${item.get { threadId }}" }
ThreadListItem(index = index, item = item) ) { index, item ->
FeedCard(
item = item,
onClick = {
navigator.navigate(
ThreadPageDestination(
threadId = it.id,
threadInfo = it
)
)
},
onAgree = {
viewModel.send(
HotUiIntent.Agree(
threadId = it.threadId,
postId = it.firstPostId,
hasAgree = it.hasAgree
)
)
},
) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
val color = when (index) {
0 -> RedA700
1 -> OrangeA700
2 -> Yellow
else -> MaterialTheme.colors.onBackground.copy(
ContentAlpha.medium
)
}
Text(
text = "${index + 1}",
color = color,
fontWeight = FontWeight.Bold,
fontSize = 16.sp
)
Text(
text = stringResource(
id = R.string.hot_num,
item.get { hotNum }.getShortNumString()
),
style = MaterialTheme.typography.caption,
color = color
)
}
}
// ThreadListItem(
// index = index,
// itemHolder = item,
// onClick = {
// navigator.navigate(
// ThreadPageDestination(
// threadId = it.id,
// threadInfo = it
// )
// )
// }
// )
} }
} }
} }
@ -322,13 +387,14 @@ private fun ThreadListItemPlaceholder() {
@Composable @Composable
private fun ThreadListItem( private fun ThreadListItem(
index: Int, index: Int,
item: ThreadInfo, itemHolder: ImmutableHolder<ThreadInfo>,
onClick: () -> Unit = {} onClick: (ThreadInfo) -> Unit = {}
) { ) {
val item = remember(itemHolder) { itemHolder.get() }
val heightModifier = if (item.media.isEmpty()) Modifier else Modifier.height(80.dp) val heightModifier = if (item.media.isEmpty()) Modifier else Modifier.height(80.dp)
Row( Row(
modifier = Modifier modifier = Modifier
.clickable(onClick = onClick) .clickable { onClick(item) }
.padding(all = 16.dp), .padding(all = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp) horizontalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
@ -368,7 +434,14 @@ private fun ThreadListItem(
) )
} }
} }
if (item.media.isNotEmpty()) { if (!item.videoInfo?.thumbnailUrl.isNullOrBlank()) {
NetworkImage(
imageUri = item.videoInfo?.thumbnailUrl!!,
contentDescription = null,
modifier = heightModifier.aspectRatio(16f / 9),
contentScale = ContentScale.Crop
)
} else if (!item.media.firstOrNull()?.dynamicPic.isNullOrBlank()) {
NetworkImage( NetworkImage(
imageUri = item.media.first().dynamicPic, imageUri = item.media.first().dynamicPic,
contentDescription = null, contentDescription = null,
@ -397,7 +470,7 @@ private fun ThreadListTab(
.clip(RoundedCornerShape(100)) .clip(RoundedCornerShape(100))
.background(backgroundColor) .background(backgroundColor)
.clickable(onClick = onSelected) .clickable(onClick = onSelected)
.padding(horizontal = 16.dp, vertical = 4.dp), .padding(vertical = 4.dp),
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )

View File

@ -2,17 +2,22 @@ package com.huanchengfly.tieba.post.ui.page.main.explore.hot
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.huanchengfly.tieba.post.api.TiebaApi import com.huanchengfly.tieba.post.api.TiebaApi
import com.huanchengfly.tieba.post.api.models.AgreeBean
import com.huanchengfly.tieba.post.api.models.protos.FrsTabInfo import com.huanchengfly.tieba.post.api.models.protos.FrsTabInfo
import com.huanchengfly.tieba.post.api.models.protos.RecommendTopicList import com.huanchengfly.tieba.post.api.models.protos.RecommendTopicList
import com.huanchengfly.tieba.post.api.models.protos.ThreadInfo import com.huanchengfly.tieba.post.api.models.protos.ThreadInfo
import com.huanchengfly.tieba.post.api.models.protos.hotThreadList.HotThreadListResponse import com.huanchengfly.tieba.post.api.models.protos.hotThreadList.HotThreadListResponse
import com.huanchengfly.tieba.post.arch.BaseViewModel 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.PartialChange
import com.huanchengfly.tieba.post.arch.PartialChangeProducer import com.huanchengfly.tieba.post.arch.PartialChangeProducer
import com.huanchengfly.tieba.post.arch.UiEvent import com.huanchengfly.tieba.post.arch.UiEvent
import com.huanchengfly.tieba.post.arch.UiIntent import com.huanchengfly.tieba.post.arch.UiIntent
import com.huanchengfly.tieba.post.arch.UiState import com.huanchengfly.tieba.post.arch.UiState
import com.huanchengfly.tieba.post.arch.wrapImmutable
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
@ -41,6 +46,8 @@ class HotViewModel @Inject constructor() :
.flatMapConcat { produceLoadPartialChange() }, .flatMapConcat { produceLoadPartialChange() },
intentFlow.filterIsInstance<HotUiIntent.RefreshThreadList>() intentFlow.filterIsInstance<HotUiIntent.RefreshThreadList>()
.flatMapConcat { it.producePartialChange() }, .flatMapConcat { it.producePartialChange() },
intentFlow.filterIsInstance<HotUiIntent.Agree>()
.flatMapConcat { it.producePartialChange() },
) )
private fun produceLoadPartialChange(): Flow<HotPartialChange.Load> = private fun produceLoadPartialChange(): Flow<HotPartialChange.Load> =
@ -65,6 +72,20 @@ class HotViewModel @Inject constructor() :
} }
.onStart { emit(HotPartialChange.RefreshThreadList.Start(tabCode)) } .onStart { emit(HotPartialChange.RefreshThreadList.Start(tabCode)) }
.catch { emit(HotPartialChange.RefreshThreadList.Failure(tabCode, it)) } .catch { emit(HotPartialChange.RefreshThreadList.Failure(tabCode, it)) }
private fun HotUiIntent.Agree.producePartialChange(): Flow<HotPartialChange.Agree> =
TiebaApi.getInstance()
.opAgreeFlow(
threadId.toString(), postId.toString(), hasAgree, objType = 3
)
.map<AgreeBean, HotPartialChange.Agree> {
HotPartialChange.Agree.Success(
threadId,
hasAgree xor 1
)
}
.onStart { emit(HotPartialChange.Agree.Start(threadId, hasAgree xor 1)) }
.catch { emit(HotPartialChange.Agree.Failure(threadId, hasAgree, it)) }
} }
} }
@ -72,6 +93,12 @@ sealed interface HotUiIntent : UiIntent {
object Load : HotUiIntent object Load : HotUiIntent
data class RefreshThreadList(val tabCode: String) : HotUiIntent data class RefreshThreadList(val tabCode: String) : HotUiIntent
data class Agree(
val threadId: Long,
val postId: Long,
val hasAgree: Int
) : HotUiIntent
} }
sealed interface HotPartialChange : PartialChange<HotUiState> { sealed interface HotPartialChange : PartialChange<HotUiState> {
@ -81,9 +108,10 @@ sealed interface HotPartialChange : PartialChange<HotUiState> {
Start -> oldState.copy(isRefreshing = true) Start -> oldState.copy(isRefreshing = true)
is Success -> oldState.copy( is Success -> oldState.copy(
isRefreshing = false, isRefreshing = false,
topicList = topicList, currentTabCode = "all",
tabList = tabList, topicList = topicList.wrapImmutable(),
threadList = threadList tabList = tabList.wrapImmutable(),
threadList = threadList.wrapImmutable()
) )
is Failure -> oldState.copy(isRefreshing = false) is Failure -> oldState.copy(isRefreshing = false)
@ -109,7 +137,7 @@ sealed interface HotPartialChange : PartialChange<HotUiState> {
is Success -> oldState.copy( is Success -> oldState.copy(
isLoadingThreadList = false, isLoadingThreadList = false,
currentTabCode = tabCode, currentTabCode = tabCode,
threadList = threadList threadList = threadList.wrapImmutable()
) )
is Failure -> oldState.copy(isLoadingThreadList = false) is Failure -> oldState.copy(isLoadingThreadList = false)
@ -127,15 +155,105 @@ sealed interface HotPartialChange : PartialChange<HotUiState> {
val error: Throwable val error: Throwable
) : RefreshThreadList() ) : RefreshThreadList()
} }
sealed class Agree private constructor() : HotPartialChange {
private fun List<ImmutableHolder<ThreadInfo>>.updateAgreeStatus(
threadId: Long,
hasAgree: Int
): ImmutableList<ImmutableHolder<ThreadInfo>> {
return map {
val threadInfo = it.get()
if (threadInfo.threadId == threadId) {
if (threadInfo.agree != null) {
if (hasAgree != threadInfo.agree.hasAgree) {
if (hasAgree == 1) {
threadInfo.copy(
agreeNum = threadInfo.agreeNum + 1,
agree = threadInfo.agree.copy(
agreeNum = threadInfo.agree.agreeNum + 1,
diffAgreeNum = threadInfo.agree.diffAgreeNum + 1,
hasAgree = 1
)
)
} else {
threadInfo.copy(
agreeNum = threadInfo.agreeNum - 1,
agree = threadInfo.agree.copy(
agreeNum = threadInfo.agree.agreeNum - 1,
diffAgreeNum = threadInfo.agree.diffAgreeNum - 1,
hasAgree = 0
)
)
}
} else {
threadInfo
}
} else {
threadInfo.copy(
agreeNum = if (hasAgree == 1) threadInfo.agreeNum + 1 else threadInfo.agreeNum - 1
)
}
} else {
threadInfo
}
}.wrapImmutable()
}
override fun reduce(oldState: HotUiState): HotUiState =
when (this) {
is Start -> {
oldState.copy(
threadList = oldState.threadList.updateAgreeStatus(
threadId,
hasAgree
)
)
}
is Success -> {
oldState.copy(
threadList = oldState.threadList.updateAgreeStatus(
threadId,
hasAgree
)
)
}
is Failure -> {
oldState.copy(
threadList = oldState.threadList.updateAgreeStatus(
threadId,
hasAgree
)
)
}
}
data class Start(
val threadId: Long,
val hasAgree: Int
) : Agree()
data class Success(
val threadId: Long,
val hasAgree: Int
) : Agree()
data class Failure(
val threadId: Long,
val hasAgree: Int,
val error: Throwable
) : Agree()
}
} }
data class HotUiState( data class HotUiState(
val isRefreshing: Boolean = true, val isRefreshing: Boolean = true,
val currentTabCode: String = "all", val currentTabCode: String = "all",
val isLoadingThreadList: Boolean = false, val isLoadingThreadList: Boolean = false,
val topicList: List<RecommendTopicList> = emptyList(), val topicList: ImmutableList<ImmutableHolder<RecommendTopicList>> = persistentListOf(),
val tabList: List<FrsTabInfo> = emptyList(), val tabList: ImmutableList<ImmutableHolder<FrsTabInfo>> = persistentListOf(),
val threadList: List<ThreadInfo> = emptyList(), val threadList: ImmutableList<ImmutableHolder<ThreadInfo>> = persistentListOf(),
) : UiState ) : UiState
sealed interface HotUiEvent : UiEvent sealed interface HotUiEvent : UiEvent

View File

@ -114,14 +114,16 @@ class PersonalizedViewModel @Inject constructor() :
.onStart { emit(PersonalizedPartialChange.Dislike.Start(threadId)) } .onStart { emit(PersonalizedPartialChange.Dislike.Start(threadId)) }
private fun PersonalizedUiIntent.Agree.producePartialChange(): Flow<PersonalizedPartialChange.Agree> = private fun PersonalizedUiIntent.Agree.producePartialChange(): Flow<PersonalizedPartialChange.Agree> =
TiebaApi.getInstance().opAgreeFlow( TiebaApi.getInstance()
threadId.toString(), postId.toString(), hasAgree, objType = 3 .opAgreeFlow(
).map<AgreeBean, PersonalizedPartialChange.Agree> { threadId.toString(), postId.toString(), hasAgree, objType = 3
PersonalizedPartialChange.Agree.Success(
threadId,
hasAgree xor 1
) )
} .map<AgreeBean, PersonalizedPartialChange.Agree> {
PersonalizedPartialChange.Agree.Success(
threadId,
hasAgree xor 1
)
}
.catch { emit(PersonalizedPartialChange.Agree.Failure(threadId, hasAgree, it)) } .catch { emit(PersonalizedPartialChange.Agree.Failure(threadId, hasAgree, it)) }
.onStart { emit(PersonalizedPartialChange.Agree.Start(threadId, hasAgree xor 1)) } .onStart { emit(PersonalizedPartialChange.Agree.Start(threadId, hasAgree xor 1)) }

View File

@ -485,7 +485,7 @@ private fun ThreadShareBtn(
fun FeedCard( fun FeedCard(
item: ImmutableHolder<ThreadInfo>, item: ImmutableHolder<ThreadInfo>,
onClick: (ThreadInfo) -> Unit, onClick: (ThreadInfo) -> Unit,
onAgree: () -> Unit, onAgree: (ThreadInfo) -> Unit,
onClickForum: () -> Unit = {}, onClickForum: () -> Unit = {},
dislikeAction: @Composable () -> Unit = {}, dislikeAction: @Composable () -> Unit = {},
) { ) {
@ -526,7 +526,7 @@ fun FeedCard(
ThreadAgreeBtn( ThreadAgreeBtn(
hasAgree = item.get { agree?.hasAgree == 1 }, hasAgree = item.get { agree?.hasAgree == 1 },
agreeNum = item.get { agreeNum }, agreeNum = item.get { agreeNum },
onClick = onAgree, onClick = { onAgree(item.get()) },
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )