新增评论回复功能

- 允许用户回复评论和子评论
- 点击回复按钮,弹出评论框,并显示回复的用户
- 评论
列表中显示回复的用户和内容
- 点击回复内容中的用户名,跳转到用户主页
- 优化评论列表加载逻辑,支持加载更多子评论
This commit is contained in:
2024-09-08 21:46:44 +08:00
parent 03a4db8a4b
commit 7c0d183571
16 changed files with 677 additions and 347 deletions

View File

@@ -38,6 +38,7 @@ class MainActivity : ComponentActivity() {
// Firebase Analytics
private lateinit var analytics: FirebaseAnalytics
private val scope = CoroutineScope(Dispatchers.Main)
// 请求通知权限
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
@@ -100,12 +101,7 @@ class MainActivity : ComponentActivity() {
val postId = intent.getStringExtra("POST_ID")
if (postId != null) {
Log.d("MainActivity", "Navigation to Post$postId")
PostViewModel.postId = postId
PostViewModel.viewModelScope.launch {
PostViewModel.initData()
navController.navigate(NavigationRoute.Post.route.replace("{id}", postId))
}
navController.navigate(NavigationRoute.Post.route.replace("{id}", postId))
}
}
}

View File

@@ -15,6 +15,9 @@ interface CommentService {
* @param postId 动态ID,过滤条件
* @param postUser 动态作者ID,获取某个用户所有动态下的评论
* @param selfNotice 是否是自己的通知
* @param order 排序
* @param parentCommentId 父评论ID
* @param pageSize 每页数量
* @return 评论列表
*/
suspend fun getComments(
@@ -22,15 +25,24 @@ interface CommentService {
postId: Int? = null,
postUser: Int? = null,
selfNotice: Boolean? = null,
order: String? = null
order: String? = null,
parentCommentId: Int? = null,
pageSize: Int? = null
): ListContainer<CommentEntity>
/**
* 创建评论
* @param postId 动态ID
* @param content 评论内容
* @param parentCommentId 父评论ID
* @param replyUserId 回复用户ID
*/
suspend fun createComment(postId: Int, content: String)
suspend fun createComment(
postId: Int,
content: String,
parentCommentId: Int? = null,
replyUserId: Int? = null
): CommentEntity
/**
* 点赞评论
@@ -87,7 +99,15 @@ data class Comment(
val post: NoticePost?,
// 是否未读
@SerializedName("isUnread")
val isUnread: Boolean
val isUnread: Boolean,
@SerializedName("reply")
val reply: List<Comment>,
@SerializedName("replyUser")
val replyUser: User?,
@SerializedName("parentCommentId")
val parentCommentId: Int?,
@SerializedName("replyCount")
val replyCount: Int
) {
/**
* 转换为Entity
@@ -114,7 +134,13 @@ data class Comment(
)
}
)
}
},
reply = reply.map { it.toCommentEntity() },
replyUserNickname = replyUser?.nickName,
replyUserId = replyUser?.id,
replyUserAvatar = replyUser?.avatar?.let { "${ApiClient.BASE_SERVER}$it" },
parentCommentId = parentCommentId,
replyCount = replyCount
)
}
}
@@ -127,14 +153,16 @@ class CommentRemoteDataSource(
postId: Int?,
postUser: Int?,
selfNotice: Boolean?,
order: String?
order: String?,
parentCommentId: Int?
): ListContainer<CommentEntity> {
return commentService.getComments(
pageNumber,
postId,
postUser = postUser,
selfNotice = selfNotice,
order = order
order = order,
parentCommentId = parentCommentId
)
}
}
@@ -146,7 +174,9 @@ class CommentServiceImpl : CommentService {
postId: Int?,
postUser: Int?,
selfNotice: Boolean?,
order: String?
order: String?,
parentCommentId: Int?,
pageSize: Int?
): ListContainer<CommentEntity> {
val resp = ApiClient.api.getComments(
page = pageNumber,
@@ -155,7 +185,9 @@ class CommentServiceImpl : CommentService {
order = order,
selfNotice = selfNotice?.let {
if (it) 1 else 0
}
},
parentCommentId = parentCommentId,
pageSize = pageSize ?: 20
)
val body = resp.body() ?: throw ServiceException("Failed to get comments")
return ListContainer(
@@ -166,9 +198,18 @@ class CommentServiceImpl : CommentService {
)
}
override suspend fun createComment(postId: Int, content: String) {
val resp = ApiClient.api.createComment(postId, CommentRequestBody(content))
return
override suspend fun createComment(
postId: Int,
content: String,
parentCommentId: Int?,
replyUserId: Int?,
): CommentEntity {
val resp = ApiClient.api.createComment(
postId,
CommentRequestBody(content, parentCommentId, replyUserId),
)
val body = resp.body() ?: throw ServiceException("Failed to create comment")
return body.data.toCommentEntity()
}
override suspend fun likeComment(commentId: Int) {

View File

@@ -61,7 +61,11 @@ data class ValidateTokenResult(
data class CommentRequestBody(
@SerializedName("content")
val content: String
val content: String,
@SerializedName("parentCommentId")
val parentCommentId: Int? = null,
@SerializedName("replyUserId")
val replyUserId: Int? = null,
)
data class ChangePasswordRequestBody(
@@ -149,7 +153,7 @@ interface RiderProAPI {
suspend fun createComment(
@Path("id") id: Int,
@Body body: CommentRequestBody
): Response<Unit>
): Response<DataContainer<Comment>>
@POST("comment/{id}/like")
suspend fun likeComment(
@@ -175,6 +179,7 @@ interface RiderProAPI {
@Query("postUser") postUser: Int? = null,
@Query("selfNotice") selfNotice: Int? = 0,
@Query("order") order: String? = null,
@Query("parentCommentId") parentCommentId: Int? = null,
): Response<ListContainer<Comment>>
@GET("account/my")

View File

@@ -19,7 +19,14 @@ data class CommentEntity(
val author: Long,
var liked: Boolean,
var unread: Boolean = false,
var post: NoticePost?
var post: NoticePost?,
var reply: List<CommentEntity>,
var replyUserId: Long?,
var replyUserNickname: String?,
var replyUserAvatar: String?,
var parentCommentId: Int?,
var replyCount: Int,
var replyPage: Int = 1
)
class CommentPagingSource(
@@ -27,7 +34,8 @@ class CommentPagingSource(
private val postId: Int? = null,
private val postUser: Int? = null,
private val selfNotice: Boolean? = null,
private val order: String? = null
private val order: String? = null,
private val parentCommentId: Int? = null
) : PagingSource<Int, CommentEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CommentEntity> {
return try {
@@ -37,7 +45,8 @@ class CommentPagingSource(
postId = postId,
postUser = postUser,
selfNotice = selfNotice,
order = order
order = order,
parentCommentId = parentCommentId
)
LoadResult.Page(
data = comments.list,

View File

@@ -132,12 +132,6 @@ fun NavigationController(
composable(
route = NavigationRoute.Post.route,
arguments = listOf(navArgument("id") { type = NavType.StringType }),
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 50))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 50))
}
) { backStackEntry ->
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
@@ -165,13 +159,6 @@ fun NavigationController(
}
composable(
route = NavigationRoute.NewPost.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 100))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 100))
}
) {
NewPostScreen()
}

View File

@@ -196,7 +196,7 @@ fun AccountEditScreen() {
if (bannerImageUrl != null) {
bannerImageUrl.toString()
} else {
it.banner
it.banner!!
},
contentDescription = null,
modifier = Modifier

View File

@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -47,24 +48,17 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.riderpro.AppState
import com.aiosman.riderpro.R
import com.aiosman.riderpro.data.CommentRemoteDataSource
import com.aiosman.riderpro.data.CommentService
import com.aiosman.riderpro.data.CommentServiceImpl
import com.aiosman.riderpro.entity.CommentEntity
import com.aiosman.riderpro.entity.CommentPagingSource
import com.aiosman.riderpro.ui.composables.EditCommentBottomModal
import com.aiosman.riderpro.ui.modifiers.noRippleClickable
import com.aiosman.riderpro.ui.post.CommentContent
import com.aiosman.riderpro.ui.post.CommentMenuModal
import com.aiosman.riderpro.ui.post.CommentsSection
import com.aiosman.riderpro.ui.post.CommentsViewModel
import com.aiosman.riderpro.ui.post.OrderSelectionComponent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
@@ -73,50 +67,13 @@ import kotlinx.coroutines.launch
class CommentModalViewModel(
val postId: Int?
) : ViewModel() {
val commentService: CommentService = CommentServiceImpl()
var commentText by mutableStateOf("")
var order by mutableStateOf("like")
val commentsFlow = MutableStateFlow<PagingData<CommentEntity>>(PagingData.empty())
var commentsViewModel: CommentsViewModel = CommentsViewModel(postId.toString())
init {
reloadComments()
commentsViewModel.preTransit()
}
fun updateDeleteComment(commentId: Int) {
viewModelScope.launch {
commentService.DeleteComment(commentId)
reloadComments()
}
}
fun reloadComments() {
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = {
CommentPagingSource(
CommentRemoteDataSource(commentService),
postId,
order = order
)
}
).flow.cachedIn(viewModelScope).collectLatest {
commentsFlow.value = it
}
}
}
/**
* 创建评论
*/
suspend fun createComment() {
postId?.let {
commentService.createComment(postId, commentText)
reloadComments()
commentText = ""
}
}
}
@@ -142,18 +99,19 @@ fun CommentModalContent(
}
}
)
val commentViewModel = model.commentsViewModel
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
LaunchedEffect(Unit) {
model.reloadComments()
}
var showCommentMenu by remember { mutableStateOf(false) }
var contextComment by remember { mutableStateOf<CommentEntity?>(null) }
val scope = rememberCoroutineScope()
val comments = model.commentsFlow.collectAsLazyPagingItems()
val insets = WindowInsets
val imePadding = insets.ime.getBottom(density = LocalDensity.current)
var bottomPadding by remember { mutableStateOf(0.dp) }
var softwareKeyboardController = LocalSoftwareKeyboardController.current
var replyComment by remember { mutableStateOf<CommentEntity?>(null) }
LaunchedEffect(imePadding) {
bottomPadding = imePadding.dp
}
@@ -179,7 +137,7 @@ fun CommentModalContent(
onDeleteClick = {
showCommentMenu = false
contextComment?.let {
model.updateDeleteComment(it.id)
commentViewModel.deleteComment(it.id)
}
}
)
@@ -188,7 +146,9 @@ fun CommentModalContent(
suspend fun sendComment() {
if (model.commentText.isNotEmpty()) {
softwareKeyboardController?.hide()
model.createComment()
commentViewModel.createComment(
model.commentText,
)
}
onCommentAdded()
}
@@ -225,8 +185,8 @@ fun CommentModalContent(
color = Color(0xff666666)
)
OrderSelectionComponent {
model.order = it
model.reloadComments()
commentViewModel.order = it
commentViewModel.reloadComment()
}
}
Box(
@@ -235,83 +195,50 @@ fun CommentModalContent(
.padding(horizontal = 16.dp)
.weight(1f)
) {
CommentsSection(
lazyPagingItems = comments,
onLike = { commentEntity: CommentEntity ->
scope.launch {
if (commentEntity.liked) {
model.commentService.dislikeComment(commentEntity.id)
} else {
model.commentService.likeComment(commentEntity.id)
}
model.reloadComments()
}
},
onLongClick = { commentEntity: CommentEntity ->
if (AppState.UserId?.toLong() == commentEntity.author) {
contextComment = commentEntity
showCommentMenu = true
}
},
onWillCollapse = {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
) {
item {
CommentContent(
viewModel = commentViewModel,
onLongClick = { commentEntity: CommentEntity ->
},
)
},
onReply = { parentComment, _, _, _ ->
},
)
Spacer(modifier = Modifier.height(72.dp))
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xfff7f7f7))
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(64.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center),
verticalAlignment = Alignment.CenterVertically
) {
// rounded
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(20.dp))
.background(Color(0xffe5e5e5))
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
BasicTextField(
value = model.commentText,
onValueChange = { text -> model.commentText = text },
modifier = Modifier
.fillMaxWidth(),
textStyle = TextStyle(
color = Color.Black,
fontWeight = FontWeight.Normal
EditCommentBottomModal(replyComment) {
commentViewModel.viewModelScope.launch {
if (replyComment != null) {
if (replyComment?.parentCommentId != null) {
// 第三级评论
commentViewModel.createComment(
it,
parentCommentId = replyComment?.parentCommentId,
replyUserId = replyComment?.author?.toInt()
)
)
} else {
// 子级评论
commentViewModel.createComment(it, replyComment?.id)
}
} else {
// 顶级评论
commentViewModel.createComment(it)
}
Spacer(modifier = Modifier.width(16.dp))
Image(
painter = painterResource(id = R.drawable.rider_pro_send),
contentDescription = "Send",
modifier = Modifier
.size(32.dp)
.noRippleClickable {
scope.launch {
sendComment()
}
}
)
}
}
Spacer(modifier = Modifier.height(navBarHeight))

View File

@@ -8,14 +8,17 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -28,18 +31,27 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.riderpro.R
import com.aiosman.riderpro.entity.CommentEntity
import com.aiosman.riderpro.ui.modifiers.noRippleClickable
@Composable
fun EditCommentBottomModal(onSend: (String) -> Unit = {}) {
fun EditCommentBottomModal(
replyComment: CommentEntity? = null,
onSend: (String) -> Unit = {}
) {
var text by remember { mutableStateOf("") }
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val focusRequester = remember { FocusRequester() }
val context = LocalContext.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
@@ -49,8 +61,61 @@ fun EditCommentBottomModal(onSend: (String) -> Unit = {}) {
modifier = Modifier
.fillMaxWidth()
.background(Color(0xfff7f7f7))
.padding(horizontal = 16.dp, vertical = 8.dp)
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
if (replyComment == null) "评论" else "回复",
fontWeight = FontWeight.W600,
modifier = Modifier.weight(1f),
fontSize = 20.sp,
fontStyle = FontStyle.Italic
)
Image(
painter = painterResource(id = R.drawable.rider_pro_send),
contentDescription = "Send",
modifier = Modifier
.size(32.dp)
.noRippleClickable {
onSend(text)
text = ""
},
)
}
Spacer(modifier = Modifier.height(16.dp))
if (replyComment != null) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
) {
CustomAsyncImage(
context,
replyComment.avatar,
modifier = Modifier.fillMaxSize(),
contentDescription = "Avatar",
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(replyComment.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
replyComment.comment,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.padding(start = 32.dp),
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(16.dp))
}
Row(
modifier = Modifier
.fillMaxWidth(),
@@ -62,7 +127,7 @@ fun EditCommentBottomModal(onSend: (String) -> Unit = {}) {
.weight(1f)
.clip(RoundedCornerShape(20.dp))
.background(Color(0xffe5e5e5))
.padding(horizontal = 16.dp, vertical = 12.dp)
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
BasicTextField(
value = text,
@@ -79,17 +144,6 @@ fun EditCommentBottomModal(onSend: (String) -> Unit = {}) {
minLines = 5
)
}
Spacer(modifier = Modifier.width(16.dp))
Image(
painter = painterResource(id = R.drawable.rider_pro_send),
contentDescription = "Send",
modifier = Modifier
.size(32.dp)
.noRippleClickable {
onSend(text)
text = ""
},
)
}
Spacer(modifier = Modifier.height(navBarHeight))
}

View File

@@ -3,6 +3,7 @@ package com.aiosman.riderpro.ui.composables
import android.content.Context
import android.graphics.Bitmap
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -16,6 +17,8 @@ import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.toDrawable
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import coil.compose.rememberImagePainter
import coil.request.ImageRequest
import coil.request.SuccessResult
import com.aiosman.riderpro.utils.BlurHashDecoder

View File

@@ -128,8 +128,8 @@ fun NotificationsScreen() {
CommentNoticeItem(comment) {
MessageListViewModel.updateReadStatus(comment.id)
MessageListViewModel.viewModelScope.launch {
PostViewModel.postId = comment.postId.toString()
PostViewModel.initData()
// PostViewModel.postId = comment.postId.toString()
// PostViewModel.initData()
navController.navigate(
NavigationRoute.Post.route.replace(
"{id}",

View File

@@ -101,6 +101,7 @@ fun MomentsList() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(
top = statusBarPaddingValues.calculateTopPadding(),
bottom = navigationBarPaddings
@@ -175,7 +176,7 @@ fun MomentCard(
modifier = Modifier
.fillMaxWidth()
.noRippleClickable {
PostViewModel.preTransit(momentEntity)
// PostViewModel.preTransit(momentEntity)
navController.navigate("Post/${momentEntity.id}")
}
) {
@@ -195,7 +196,11 @@ fun MomentCard(
navController.navigate(NavigationRoute.NewPost.route)
},
onFavoriteClick = onFavoriteClick,
imageIndex = imageIndex
imageIndex = imageIndex,
onCommentClick = {
// PostViewModel.preTransit(momentEntity)
navController.navigate("Post/${momentEntity.id}")
}
)
}
}
@@ -458,11 +463,13 @@ fun MomentOperateBtn(count: String, content: @Composable () -> Unit) {
fun MomentBottomOperateRowGroup(
onLikeClick: () -> Unit = {},
onAddComment: () -> Unit = {},
onCommentClick: () -> Unit = {},
onFavoriteClick: () -> Unit = {},
onShareClick: () -> Unit = {},
momentEntity: MomentEntity,
imageIndex: Int = 0
) {
val navController = LocalNavController.current
var showCommentModal by remember { mutableStateOf(false) }
if (showCommentModal) {
ModalBottomSheet(
@@ -521,7 +528,7 @@ fun MomentBottomOperateRowGroup(
modifier = Modifier
.fillMaxHeight()
.noRippleClickable {
showCommentModal = true
onCommentClick()
},
contentAlignment = Alignment.Center
) {

View File

@@ -820,7 +820,7 @@ fun MomentCardPicture(imageUrl: String, momentEntity: MomentEntity) {
.aspectRatio(3f / 2f)
.padding(16.dp)
.noRippleClickable {
PostViewModel.preTransit(momentEntity)
// PostViewModel.preTransit(momentEntity)
navController.navigate(
NavigationRoute.Post.route.replace(
"{id}",

View File

@@ -158,7 +158,7 @@ fun DiscoverView() {
.padding(2.dp)
.noRippleClickable {
PostViewModel.preTransit(momentItem)
// PostViewModel.preTransit(momentItem)
navController.navigate(
NavigationRoute.Post.route.replace(
"{id}",

View File

@@ -0,0 +1,163 @@
package com.aiosman.riderpro.ui.post
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import com.aiosman.riderpro.data.CommentRemoteDataSource
import com.aiosman.riderpro.data.CommentService
import com.aiosman.riderpro.data.CommentServiceImpl
import com.aiosman.riderpro.entity.CommentEntity
import com.aiosman.riderpro.entity.CommentPagingSource
import com.aiosman.riderpro.ui.index.tabs.moment.MomentViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class CommentsViewModel(
var postId: String = 0.toString(),
) : ViewModel() {
var commentService: CommentService = CommentServiceImpl()
private var _commentsFlow = MutableStateFlow<PagingData<CommentEntity>>(PagingData.empty())
val commentsFlow = _commentsFlow.asStateFlow()
var order: String by mutableStateOf("like")
var addedCommentList by mutableStateOf<List<CommentEntity>>(emptyList())
/**
* 预加载,在跳转到 PostScreen 之前设置好内容
*/
fun preTransit() {
viewModelScope.launch {
Pager(config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
CommentPagingSource(
CommentRemoteDataSource(commentService),
postId = postId.toInt()
)
}).flow.cachedIn(viewModelScope).collectLatest {
_commentsFlow.value = it
}
}
}
fun reloadComment() {
viewModelScope.launch {
Pager(config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
CommentPagingSource(
CommentRemoteDataSource(commentService),
postId = postId.toInt(),
order = order
)
}).flow.cachedIn(viewModelScope).collectLatest {
_commentsFlow.value = it
}
}
}
suspend fun likeComment(commentId: Int) {
commentService.likeComment(commentId)
val currentPagingData = commentsFlow.value
val updatedPagingData = currentPagingData.map { comment ->
if (comment.id == commentId) {
comment.copy(liked = !comment.liked, likes = comment.likes + 1)
} else {
// 可能是回复的评论
comment.copy(reply = comment.reply.map { replyComment ->
if (replyComment.id == commentId) {
replyComment.copy(
liked = !replyComment.liked, likes = replyComment.likes + 1
)
} else {
replyComment
}
})
}
}
_commentsFlow.value = updatedPagingData
// 更新addCommentList
addedCommentList = addedCommentList.map {
if (it.id == commentId) {
it.copy(liked = !it.liked, likes = it.likes + 1)
} else {
it
}
}
}
suspend fun unlikeComment(commentId: Int) {
commentService.dislikeComment(commentId)
val currentPagingData = commentsFlow.value
val updatedPagingData = currentPagingData.map { comment ->
if (comment.id == commentId) {
comment.copy(liked = !comment.liked, likes = comment.likes - 1)
} else {
// 可能是回复的评论
comment.copy(reply = comment.reply.map { replyComment ->
if (replyComment.id == commentId) {
replyComment.copy(
liked = !replyComment.liked, likes = replyComment.likes - 1
)
} else {
replyComment
}
})
}
}
_commentsFlow.value = updatedPagingData
// 更新addCommentList
addedCommentList = addedCommentList.map {
if (it.id == commentId) {
it.copy(liked = !it.liked, likes = it.likes - 1)
} else {
it
}
}
}
suspend fun createComment(
content: String, parentCommentId: Int? = null, replyUserId: Int? = null
) {
val comment =
commentService.createComment(postId.toInt(), content, parentCommentId, replyUserId)
MomentViewModel.updateCommentCount(postId.toInt())
addedCommentList = addedCommentList.plus(comment)
}
fun deleteComment(commentId: Int) {
viewModelScope.launch {
commentService.DeleteComment(commentId)
reloadComment()
}
}
fun loadMoreSubComments(commentId: Int) {
viewModelScope.launch {
val currentPagingData = commentsFlow.value
val updatedPagingData = currentPagingData.map { comment ->
if (comment.id == commentId) {
val subCommentList = commentService.getComments(
postId = postId.toInt(),
parentCommentId = commentId,
pageNumber = comment.replyPage + 1,
pageSize = 3,
).list
return@map comment.copy(
reply = comment.reply.plus(subCommentList),
replyPage = comment.replyPage + 1
)
}
comment
}
_commentsFlow.value = updatedPagingData
}
}
}

View File

@@ -30,6 +30,7 @@ import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
@@ -54,10 +55,17 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.riderpro.AppState
@@ -71,6 +79,7 @@ import com.aiosman.riderpro.entity.MomentImageEntity
import com.aiosman.riderpro.exp.formatPostTime
import com.aiosman.riderpro.exp.timeAgo
import com.aiosman.riderpro.ui.NavigationRoute
import com.aiosman.riderpro.ui.comment.CommentModalViewModel
import com.aiosman.riderpro.ui.composables.AnimatedFavouriteIcon
import com.aiosman.riderpro.ui.composables.AnimatedLikeIcon
import com.aiosman.riderpro.ui.composables.BottomNavigationPlaceholder
@@ -86,12 +95,21 @@ import kotlinx.coroutines.launch
fun PostScreen(
id: String,
) {
val viewModel = PostViewModel
val viewModel = viewModel<PostViewModel>(
key = "PostViewModel_$id",
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return PostViewModel(id) as T
}
}
)
val commentsViewModel = viewModel.commentsViewModel
val scope = rememberCoroutineScope()
val commentsPagging = viewModel.commentsFlow.collectAsLazyPagingItems()
val navController = LocalNavController.current
var showCommentMenu by remember { mutableStateOf(false) }
var contextComment by remember { mutableStateOf<CommentEntity?>(null) }
var replyComment by remember { mutableStateOf<CommentEntity?>(null) }
var showCommentModal by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.initData()
}
@@ -118,6 +136,43 @@ fun PostScreen(
)
}
}
if (showCommentModal) {
ModalBottomSheet(
onDismissRequest = {
showCommentModal = false
},
containerColor = Color.White,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
dragHandle = {},
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
windowInsets = WindowInsets(0)
) {
EditCommentBottomModal(replyComment) {
viewModel.viewModelScope.launch {
if (replyComment != null) {
if (replyComment?.parentCommentId != null) {
// 第三级评论
viewModel.createComment(
it,
parentCommentId = replyComment?.parentCommentId,
replyUserId = replyComment?.author?.toInt()
)
} else {
// 子级评论
viewModel.createComment(it, replyComment?.id)
}
} else {
// 顶级评论
viewModel.createComment(it)
}
showCommentModal = false
}
}
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
@@ -131,10 +186,9 @@ fun PostScreen(
}
}
},
onCreateComment = {
scope.launch {
viewModel.createComment(it)
}
onCreateCommentClick = {
replyComment = null
showCommentModal = true
},
onFavoriteClick = {
scope.launch {
@@ -221,39 +275,25 @@ fun PostScreen(
)
Spacer(modifier = Modifier.weight(1f))
OrderSelectionComponent() {
viewModel.order = it
commentsViewModel.order = it
viewModel.reloadComment()
}
}
Spacer(modifier = Modifier.height(16.dp))
}
items(commentsPagging.itemCount) { idx ->
val item = commentsPagging[idx] ?: return@items
Box(
modifier = Modifier.padding(horizontal = 16.dp)
) {
CommentItem(
item,
onLike = {
scope.launch {
if (item.liked) {
viewModel.unlikeComment(item.id)
} else {
viewModel.likeComment(item.id)
}
}
},
onLongClick = {
if (AppState.UserId != item.id) {
return@CommentItem
}
showCommentMenu = true
contextComment = item
}
)
}
item {
CommentContent(
viewModel = commentsViewModel,
onLongClick = {
showCommentMenu = true
contextComment = it
},
onReply = { parentComment, _, _, _ ->
replyComment = parentComment
showCommentModal = true
}
)
}
item {
Spacer(modifier = Modifier.height(72.dp))
@@ -264,7 +304,88 @@ fun PostScreen(
}
}
@Composable
fun CommentContent(
viewModel:CommentsViewModel,
onLongClick: (CommentEntity) -> Unit,
onReply: (CommentEntity, Long?, String?, String?) -> Unit
){
val commentsPagging = viewModel.commentsFlow.collectAsLazyPagingItems()
val addedTopLevelComment = viewModel.addedCommentList.filter {
it.parentCommentId == null
}
for (item in addedTopLevelComment){
Box(
modifier = Modifier.padding(horizontal = 16.dp)
) {
CommentItem(
item,
onLike = { comment ->
viewModel.viewModelScope.launch {
if (comment.liked) {
viewModel.unlikeComment(comment.id)
} else {
viewModel.likeComment(comment.id)
}
}
},
onLongClick = {
if (AppState.UserId != item.id) {
return@CommentItem
}
onLongClick(item)
},
onReply = { parentComment, _, _, _ ->
onReply(parentComment, parentComment.author, parentComment.name, parentComment.avatar)
},
onLoadMoreSubComments = {
viewModel.viewModelScope.launch {
viewModel.loadMoreSubComments(it.id)
}
},
addedCommentList = viewModel.addedCommentList
)
}
}
for (idx in 0 until commentsPagging.itemCount){
val item = commentsPagging[idx] ?: return
Box(
modifier = Modifier.padding(horizontal = 16.dp)
) {
CommentItem(
item,
onLike = { comment ->
viewModel.viewModelScope.launch {
if (comment.liked) {
viewModel.unlikeComment(comment.id)
} else {
viewModel.likeComment(comment.id)
}
}
},
onLongClick = {
if (AppState.UserId != item.id) {
return@CommentItem
}
onLongClick(item)
},
onReply = { parentComment, _, _, _ ->
onReply(parentComment, parentComment.author, parentComment.name, parentComment.avatar)
},
onLoadMoreSubComments = {
viewModel.viewModelScope.launch {
viewModel.loadMoreSubComments(it.id)
}
},
addedCommentList = viewModel.addedCommentList
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Header(
@@ -526,8 +647,17 @@ fun CommentsSection(
@Composable
fun CommentItem(
commentEntity: CommentEntity,
onLike: () -> Unit = {},
onLongClick: () -> Unit = {}
isChild: Boolean = false,
onLike: (commentEntity: CommentEntity) -> Unit = {},
onReply: (
parentComment: CommentEntity,
replyUserId: Long?,
replyUserNickname: String?,
replyUserAvatar: String?
) -> Unit = { _, _, _, _ -> },
onLoadMoreSubComments: ((CommentEntity) -> Unit)? = {},
onLongClick: () -> Unit = {},
addedCommentList: List<CommentEntity> = emptyList()
) {
val context = LocalContext.current
val navController = LocalNavController.current
@@ -544,7 +674,7 @@ fun CommentItem(
Row(modifier = Modifier.padding(vertical = 8.dp)) {
Box(
modifier = Modifier
.size(40.dp)
.size(if (isChild) 24.dp else 40.dp)
.background(Color.Gray.copy(alpha = 0.1f))
.noRippleClickable {
navController.navigate(
@@ -559,7 +689,7 @@ fun CommentItem(
context = context,
imageUrl = commentEntity.avatar,
contentDescription = "Comment Profile Picture",
modifier = Modifier.size(40.dp),
modifier = Modifier.size(if (isChild) 24.dp else 40.dp),
contentScale = ContentScale.Crop
)
}
@@ -568,12 +698,77 @@ fun CommentItem(
modifier = Modifier.weight(1f)
) {
Text(text = commentEntity.name, fontWeight = FontWeight.W600, fontSize = 14.sp)
Text(text = commentEntity.comment, fontSize = 14.sp)
Text(
text = commentEntity.date.timeAgo(context),
fontSize = 12.sp,
color = Color.Gray
)
Row {
if (isChild) {
val annotatedText = buildAnnotatedString {
if (commentEntity.replyUserId != null) {
pushStringAnnotation(
tag = "replyUser",
annotation = commentEntity.replyUserId.toString()
)
withStyle(
style = SpanStyle(
fontWeight = FontWeight.W600,
color = Color(0xFF6F94AE)
)
) {
append("@${commentEntity.replyUserNickname}")
}
pop()
}
append(" ${commentEntity.comment}")
}
ClickableText(
text = annotatedText,
onClick = { offset ->
annotatedText.getStringAnnotations(
tag = "replyUser",
start = offset,
end = offset
).firstOrNull()?.let {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
it.item
)
)
}
},
style = TextStyle(fontSize = 14.sp),
maxLines = Int.MAX_VALUE,
softWrap = true
)
} else {
Text(
text = commentEntity.comment,
fontSize = 14.sp,
maxLines = Int.MAX_VALUE,
softWrap = true
)
}
}
Row {
Text(
text = commentEntity.date.timeAgo(context),
fontSize = 12.sp,
color = Color.Gray
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Reply",
fontSize = 12.sp,
color = Color.Gray,
modifier = Modifier.noRippleClickable {
onReply(
commentEntity,
commentEntity.replyUserId,
commentEntity.replyUserNickname,
commentEntity.replyUserAvatar
)
}
)
}
}
Spacer(modifier = Modifier.width(16.dp))
Column(
@@ -581,7 +776,9 @@ fun CommentItem(
) {
AnimatedLikeIcon(
liked = commentEntity.liked,
onClick = onLike,
onClick = {
onLike(commentEntity)
},
modifier = Modifier.size(20.dp)
)
Text(text = commentEntity.likes.toString(), fontSize = 12.sp)
@@ -589,11 +786,40 @@ fun CommentItem(
}
Spacer(modifier = Modifier.height(8.dp))
Column(
modifier = Modifier.padding(start = 16.dp)
modifier = Modifier.padding(start = 12.dp + 40.dp)
) {
commentEntity.replies.forEach { reply ->
CommentItem(reply)
val addedCommentList =
addedCommentList.filter { it.parentCommentId == commentEntity.id }
addedCommentList.forEach { addedComment ->
CommentItem(
addedComment,
isChild = true,
onLike = onLike,
onReply = onReply,
onLongClick = onLongClick
)
}
commentEntity.reply.forEach { reply ->
CommentItem(
reply,
isChild = true,
onLike = onLike,
onReply = onReply,
onLongClick = onLongClick
)
}
if (commentEntity.replyCount > 0 && !isChild && commentEntity.reply.size < commentEntity.replyCount) {
val remaining = commentEntity.replyCount - commentEntity.reply.size
Text(
text = "View $remaining more replies",
fontSize = 12.sp,
color = Color(0xFF6F94AE),
modifier = Modifier.noRippleClickable {
onLoadMoreSubComments?.invoke(commentEntity)
}
)
}
}
}
}
@@ -601,29 +827,12 @@ fun CommentItem(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PostBottomBar(
onCreateComment: (String) -> Unit = {},
onCreateCommentClick: () -> Unit = {},
onLikeClick: () -> Unit = {},
onFavoriteClick: () -> Unit = {},
momentEntity: MomentEntity?
) {
var showCommentModal by remember { mutableStateOf(false) }
if (showCommentModal) {
ModalBottomSheet(
onDismissRequest = { showCommentModal = false },
containerColor = Color.White,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
dragHandle = {},
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
windowInsets = WindowInsets(0)
) {
EditCommentBottomModal() {
onCreateComment(it)
showCommentModal = false
}
}
}
Column(
modifier = Modifier.background(Color.White)
) {
@@ -650,7 +859,7 @@ fun PostBottomBar(
.height(31.dp)
.padding(8.dp)
.noRippleClickable {
showCommentModal = true
onCreateCommentClick()
}
) {
Row(

View File

@@ -5,135 +5,62 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.map
import com.aiosman.riderpro.data.AccountService
import com.aiosman.riderpro.data.AccountServiceImpl
import com.aiosman.riderpro.data.CommentRemoteDataSource
import com.aiosman.riderpro.data.CommentService
import com.aiosman.riderpro.data.CommentServiceImpl
import com.aiosman.riderpro.data.MomentService
import com.aiosman.riderpro.data.UserService
import com.aiosman.riderpro.data.UserServiceImpl
import com.aiosman.riderpro.entity.AccountProfileEntity
import com.aiosman.riderpro.entity.CommentEntity
import com.aiosman.riderpro.entity.CommentPagingSource
import com.aiosman.riderpro.entity.MomentEntity
import com.aiosman.riderpro.entity.MomentServiceImpl
import com.aiosman.riderpro.ui.index.tabs.moment.MomentViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
object PostViewModel : ViewModel() {
var service: MomentService = MomentServiceImpl()
var commentService: CommentService = CommentServiceImpl()
var userService: UserService = UserServiceImpl()
private var _commentsFlow = MutableStateFlow<PagingData<CommentEntity>>(PagingData.empty())
val commentsFlow = _commentsFlow.asStateFlow()
var postId: String = ""
var order: String by mutableStateOf("like")
// 预加载的 moment
class PostViewModel(
val postId: String
) : ViewModel() {
var service: MomentService = MomentServiceImpl()
var userService: UserService = UserServiceImpl()
var accountProfileEntity by mutableStateOf<AccountProfileEntity?>(null)
var moment by mutableStateOf<MomentEntity?>(null)
var accountService: AccountService = AccountServiceImpl()
var commentsViewModel: CommentsViewModel = CommentsViewModel(postId)
/**
* 预加载,在跳转到 PostScreen 之前设置好内容
*/
fun preTransit(momentEntity: MomentEntity?) {
this.postId = momentEntity?.id.toString()
this.moment = momentEntity
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
CommentPagingSource(
CommentRemoteDataSource(commentService),
postId = postId.toInt()
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_commentsFlow.value = it
}
}
this.nickname = momentEntity?.nickname ?: ""
this.commentsViewModel = CommentsViewModel(postId)
commentsViewModel.preTransit()
}
fun reloadComment() {
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
CommentPagingSource(
CommentRemoteDataSource(commentService),
postId = postId.toInt(),
order = order
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_commentsFlow.value = it
}
}
commentsViewModel.reloadComment()
}
suspend fun initData() {
moment = service.getMomentById(postId.toInt())
accountProfileEntity = userService.getUserProfile(moment?.authorId.toString())
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
CommentPagingSource(
CommentRemoteDataSource(commentService),
postId = postId.toInt()
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_commentsFlow.value = it
}
}
// moment?.let {
// accountProfileEntity = userService.getUserProfile(it.authorId.toString())
// }
// accountProfileEntity = userService.getUserProfile(moment?.authorId.toString())
commentsViewModel.reloadComment()
}
suspend fun likeComment(commentId: Int) {
commentService.likeComment(commentId)
val currentPagingData = commentsFlow.value
val updatedPagingData = currentPagingData.map { comment ->
if (comment.id == commentId) {
comment.copy(liked = !comment.liked, likes = comment.likes + 1)
} else {
comment
}
}
_commentsFlow.value = updatedPagingData
commentsViewModel.likeComment(commentId)
}
suspend fun unlikeComment(commentId: Int) {
commentService.dislikeComment(commentId)
val currentPagingData = commentsFlow.value
val updatedPagingData = currentPagingData.map { comment ->
if (comment.id == commentId) {
comment.copy(liked = !comment.liked, likes = comment.likes - 1)
} else {
comment
}
}
_commentsFlow.value = updatedPagingData
commentsViewModel.unlikeComment(commentId)
}
suspend fun createComment(content: String) {
commentService.createComment(postId.toInt(), content)
this.moment = service.getMomentById(postId.toInt())
MomentViewModel.updateCommentCount(postId.toInt())
reloadComment()
suspend fun createComment(
content: String, parentCommentId: Int? = null, replyUserId: Int? = null
) {
commentsViewModel.createComment(content, parentCommentId, replyUserId)
}
suspend fun likeMoment() {
@@ -165,8 +92,7 @@ object PostViewModel : ViewModel() {
moment?.let {
service.unfavoriteMoment(it.id)
moment = moment?.copy(
favoriteCount = moment?.favoriteCount?.minus(1) ?: 0,
isFavorite = false
favoriteCount = moment?.favoriteCount?.minus(1) ?: 0, isFavorite = false
)
}
}
@@ -184,14 +110,12 @@ object PostViewModel : ViewModel() {
accountProfileEntity = accountProfileEntity?.copy(isFollowing = false)
}
}
fun deleteComment(commentId: Int) {
viewModelScope.launch {
commentService.DeleteComment(commentId)
moment = moment?.copy(commentCount = moment?.commentCount?.minus(1) ?: 0)
reloadComment()
moment?.let {
MomentViewModel.updateMomentCommentCount(it.id, -1)
}
commentsViewModel.deleteComment(commentId)
moment = moment?.copy(commentCount = moment?.commentCount?.minus(1) ?: 0)
moment?.let {
MomentViewModel.updateMomentCommentCount(it.id, -1)
}
}
@@ -215,6 +139,7 @@ object PostViewModel : ViewModel() {
}
return field
}
fun deleteMoment(callback: () -> Unit) {
viewModelScope.launch {
moment?.let {
@@ -225,4 +150,8 @@ object PostViewModel : ViewModel() {
}
}
fun loadMoreSubComments(commentId: Int) {
commentsViewModel.loadMoreSubComments(commentId)
}
}