新增评论回复功能

- 允许用户回复评论和子评论
- 点击回复按钮,弹出评论框,并显示回复的用户
- 评论
列表中显示回复的用户和内容
- 点击回复内容中的用户名,跳转到用户主页
- 优化评论列表加载逻辑,支持加载更多子评论
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 // Firebase Analytics
private lateinit var analytics: FirebaseAnalytics private lateinit var analytics: FirebaseAnalytics
private val scope = CoroutineScope(Dispatchers.Main) private val scope = CoroutineScope(Dispatchers.Main)
// 请求通知权限 // 请求通知权限
private val requestPermissionLauncher = registerForActivityResult( private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
@@ -100,13 +101,8 @@ class MainActivity : ComponentActivity() {
val postId = intent.getStringExtra("POST_ID") val postId = intent.getStringExtra("POST_ID")
if (postId != null) { if (postId != null) {
Log.d("MainActivity", "Navigation to Post$postId") 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 postId 动态ID,过滤条件
* @param postUser 动态作者ID,获取某个用户所有动态下的评论 * @param postUser 动态作者ID,获取某个用户所有动态下的评论
* @param selfNotice 是否是自己的通知 * @param selfNotice 是否是自己的通知
* @param order 排序
* @param parentCommentId 父评论ID
* @param pageSize 每页数量
* @return 评论列表 * @return 评论列表
*/ */
suspend fun getComments( suspend fun getComments(
@@ -22,15 +25,24 @@ interface CommentService {
postId: Int? = null, postId: Int? = null,
postUser: Int? = null, postUser: Int? = null,
selfNotice: Boolean? = null, selfNotice: Boolean? = null,
order: String? = null order: String? = null,
parentCommentId: Int? = null,
pageSize: Int? = null
): ListContainer<CommentEntity> ): ListContainer<CommentEntity>
/** /**
* 创建评论 * 创建评论
* @param postId 动态ID * @param postId 动态ID
* @param content 评论内容 * @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?, val post: NoticePost?,
// 是否未读 // 是否未读
@SerializedName("isUnread") @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 * 转换为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?, postId: Int?,
postUser: Int?, postUser: Int?,
selfNotice: Boolean?, selfNotice: Boolean?,
order: String? order: String?,
parentCommentId: Int?
): ListContainer<CommentEntity> { ): ListContainer<CommentEntity> {
return commentService.getComments( return commentService.getComments(
pageNumber, pageNumber,
postId, postId,
postUser = postUser, postUser = postUser,
selfNotice = selfNotice, selfNotice = selfNotice,
order = order order = order,
parentCommentId = parentCommentId
) )
} }
} }
@@ -146,7 +174,9 @@ class CommentServiceImpl : CommentService {
postId: Int?, postId: Int?,
postUser: Int?, postUser: Int?,
selfNotice: Boolean?, selfNotice: Boolean?,
order: String? order: String?,
parentCommentId: Int?,
pageSize: Int?
): ListContainer<CommentEntity> { ): ListContainer<CommentEntity> {
val resp = ApiClient.api.getComments( val resp = ApiClient.api.getComments(
page = pageNumber, page = pageNumber,
@@ -155,7 +185,9 @@ class CommentServiceImpl : CommentService {
order = order, order = order,
selfNotice = selfNotice?.let { selfNotice = selfNotice?.let {
if (it) 1 else 0 if (it) 1 else 0
} },
parentCommentId = parentCommentId,
pageSize = pageSize ?: 20
) )
val body = resp.body() ?: throw ServiceException("Failed to get comments") val body = resp.body() ?: throw ServiceException("Failed to get comments")
return ListContainer( return ListContainer(
@@ -166,9 +198,18 @@ class CommentServiceImpl : CommentService {
) )
} }
override suspend fun createComment(postId: Int, content: String) { override suspend fun createComment(
val resp = ApiClient.api.createComment(postId, CommentRequestBody(content)) postId: Int,
return 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) { override suspend fun likeComment(commentId: Int) {

View File

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

View File

@@ -19,7 +19,14 @@ data class CommentEntity(
val author: Long, val author: Long,
var liked: Boolean, var liked: Boolean,
var unread: Boolean = false, 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( class CommentPagingSource(
@@ -27,7 +34,8 @@ class CommentPagingSource(
private val postId: Int? = null, private val postId: Int? = null,
private val postUser: Int? = null, private val postUser: Int? = null,
private val selfNotice: Boolean? = null, private val selfNotice: Boolean? = null,
private val order: String? = null private val order: String? = null,
private val parentCommentId: Int? = null
) : PagingSource<Int, CommentEntity>() { ) : PagingSource<Int, CommentEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CommentEntity> { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CommentEntity> {
return try { return try {
@@ -37,7 +45,8 @@ class CommentPagingSource(
postId = postId, postId = postId,
postUser = postUser, postUser = postUser,
selfNotice = selfNotice, selfNotice = selfNotice,
order = order order = order,
parentCommentId = parentCommentId
) )
LoadResult.Page( LoadResult.Page(
data = comments.list, data = comments.list,

View File

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

View File

@@ -196,7 +196,7 @@ fun AccountEditScreen() {
if (bannerImageUrl != null) { if (bannerImageUrl != null) {
bannerImageUrl.toString() bannerImageUrl.toString()
} else { } else {
it.banner it.banner!!
}, },
contentDescription = null, contentDescription = null,
modifier = Modifier 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -47,24 +48,17 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel 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 androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.riderpro.AppState import com.aiosman.riderpro.AppState
import com.aiosman.riderpro.R 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.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.modifiers.noRippleClickable
import com.aiosman.riderpro.ui.post.CommentContent
import com.aiosman.riderpro.ui.post.CommentMenuModal import com.aiosman.riderpro.ui.post.CommentMenuModal
import com.aiosman.riderpro.ui.post.CommentsSection import com.aiosman.riderpro.ui.post.CommentsSection
import com.aiosman.riderpro.ui.post.CommentsViewModel
import com.aiosman.riderpro.ui.post.OrderSelectionComponent import com.aiosman.riderpro.ui.post.OrderSelectionComponent
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -73,50 +67,13 @@ import kotlinx.coroutines.launch
class CommentModalViewModel( class CommentModalViewModel(
val postId: Int? val postId: Int?
) : ViewModel() { ) : ViewModel() {
val commentService: CommentService = CommentServiceImpl()
var commentText by mutableStateOf("") var commentText by mutableStateOf("")
var order by mutableStateOf("like") var commentsViewModel: CommentsViewModel = CommentsViewModel(postId.toString())
val commentsFlow = MutableStateFlow<PagingData<CommentEntity>>(PagingData.empty())
init { 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() var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
model.reloadComments()
} }
var showCommentMenu by remember { mutableStateOf(false) } var showCommentMenu by remember { mutableStateOf(false) }
var contextComment by remember { mutableStateOf<CommentEntity?>(null) } var contextComment by remember { mutableStateOf<CommentEntity?>(null) }
val scope = rememberCoroutineScope()
val comments = model.commentsFlow.collectAsLazyPagingItems()
val insets = WindowInsets val insets = WindowInsets
val imePadding = insets.ime.getBottom(density = LocalDensity.current) val imePadding = insets.ime.getBottom(density = LocalDensity.current)
var bottomPadding by remember { mutableStateOf(0.dp) } var bottomPadding by remember { mutableStateOf(0.dp) }
var softwareKeyboardController = LocalSoftwareKeyboardController.current var softwareKeyboardController = LocalSoftwareKeyboardController.current
var replyComment by remember { mutableStateOf<CommentEntity?>(null) }
LaunchedEffect(imePadding) { LaunchedEffect(imePadding) {
bottomPadding = imePadding.dp bottomPadding = imePadding.dp
} }
@@ -179,7 +137,7 @@ fun CommentModalContent(
onDeleteClick = { onDeleteClick = {
showCommentMenu = false showCommentMenu = false
contextComment?.let { contextComment?.let {
model.updateDeleteComment(it.id) commentViewModel.deleteComment(it.id)
} }
} }
) )
@@ -188,7 +146,9 @@ fun CommentModalContent(
suspend fun sendComment() { suspend fun sendComment() {
if (model.commentText.isNotEmpty()) { if (model.commentText.isNotEmpty()) {
softwareKeyboardController?.hide() softwareKeyboardController?.hide()
model.createComment() commentViewModel.createComment(
model.commentText,
)
} }
onCommentAdded() onCommentAdded()
} }
@@ -225,8 +185,8 @@ fun CommentModalContent(
color = Color(0xff666666) color = Color(0xff666666)
) )
OrderSelectionComponent { OrderSelectionComponent {
model.order = it commentViewModel.order = it
model.reloadComments() commentViewModel.reloadComment()
} }
} }
Box( Box(
@@ -235,82 +195,49 @@ fun CommentModalContent(
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.weight(1f) .weight(1f)
) { ) {
CommentsSection( LazyColumn(
lazyPagingItems = comments, modifier = Modifier
onLike = { commentEntity: CommentEntity -> .fillMaxWidth()
scope.launch { ) {
if (commentEntity.liked) { item {
model.commentService.dislikeComment(commentEntity.id) CommentContent(
} else { viewModel = commentViewModel,
model.commentService.likeComment(commentEntity.id)
}
model.reloadComments()
}
},
onLongClick = { commentEntity: CommentEntity -> onLongClick = { commentEntity: CommentEntity ->
if (AppState.UserId?.toLong() == commentEntity.author) {
contextComment = commentEntity
showCommentMenu = true
}
}, },
onWillCollapse = { onReply = { parentComment, _, _, _ ->
}, },
) )
Spacer(modifier = Modifier.height(72.dp))
}
}
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color(0xfff7f7f7)) .background(Color(0xfff7f7f7))
) { ) {
Box( EditCommentBottomModal(replyComment) {
modifier = Modifier commentViewModel.viewModelScope.launch {
.fillMaxWidth() if (replyComment != null) {
.padding(horizontal = 16.dp) if (replyComment?.parentCommentId != null) {
.height(64.dp) // 第三级评论
) { commentViewModel.createComment(
Row( it,
modifier = Modifier parentCommentId = replyComment?.parentCommentId,
.fillMaxWidth() replyUserId = replyComment?.author?.toInt()
.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
) )
) } else {
// 子级评论
commentViewModel.createComment(it, replyComment?.id)
} }
Spacer(modifier = Modifier.width(16.dp)) } else {
Image( // 顶级评论
painter = painterResource(id = R.drawable.rider_pro_send), commentViewModel.createComment(it)
contentDescription = "Send",
modifier = Modifier
.size(32.dp)
.noRippleClickable {
scope.launch {
sendComment()
} }
} }
)
}
} }
Spacer(modifier = Modifier.height(navBarHeight)) 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.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.riderpro.R import com.aiosman.riderpro.R
import com.aiosman.riderpro.entity.CommentEntity
import com.aiosman.riderpro.ui.modifiers.noRippleClickable import com.aiosman.riderpro.ui.modifiers.noRippleClickable
@Composable @Composable
fun EditCommentBottomModal(onSend: (String) -> Unit = {}) { fun EditCommentBottomModal(
replyComment: CommentEntity? = null,
onSend: (String) -> Unit = {}
) {
var text by remember { mutableStateOf("") } var text by remember { mutableStateOf("") }
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
@@ -49,8 +61,61 @@ fun EditCommentBottomModal(onSend: (String) -> Unit = {}) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color(0xfff7f7f7)) .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( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
@@ -62,7 +127,7 @@ fun EditCommentBottomModal(onSend: (String) -> Unit = {}) {
.weight(1f) .weight(1f)
.clip(RoundedCornerShape(20.dp)) .clip(RoundedCornerShape(20.dp))
.background(Color(0xffe5e5e5)) .background(Color(0xffe5e5e5))
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 16.dp)
) { ) {
BasicTextField( BasicTextField(
value = text, value = text,
@@ -79,17 +144,6 @@ fun EditCommentBottomModal(onSend: (String) -> Unit = {}) {
minLines = 5 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)) Spacer(modifier = Modifier.height(navBarHeight))
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -158,7 +158,7 @@ fun DiscoverView() {
.padding(2.dp) .padding(2.dp)
.noRippleClickable { .noRippleClickable {
PostViewModel.preTransit(momentItem) // PostViewModel.preTransit(momentItem)
navController.navigate( navController.navigate(
NavigationRoute.Post.route.replace( NavigationRoute.Post.route.replace(
"{id}", "{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.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold 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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.riderpro.AppState 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.formatPostTime
import com.aiosman.riderpro.exp.timeAgo import com.aiosman.riderpro.exp.timeAgo
import com.aiosman.riderpro.ui.NavigationRoute 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.AnimatedFavouriteIcon
import com.aiosman.riderpro.ui.composables.AnimatedLikeIcon import com.aiosman.riderpro.ui.composables.AnimatedLikeIcon
import com.aiosman.riderpro.ui.composables.BottomNavigationPlaceholder import com.aiosman.riderpro.ui.composables.BottomNavigationPlaceholder
@@ -86,12 +95,21 @@ import kotlinx.coroutines.launch
fun PostScreen( fun PostScreen(
id: String, 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 scope = rememberCoroutineScope()
val commentsPagging = viewModel.commentsFlow.collectAsLazyPagingItems()
val navController = LocalNavController.current val navController = LocalNavController.current
var showCommentMenu by remember { mutableStateOf(false) } var showCommentMenu by remember { mutableStateOf(false) }
var contextComment by remember { mutableStateOf<CommentEntity?>(null) } var contextComment by remember { mutableStateOf<CommentEntity?>(null) }
var replyComment by remember { mutableStateOf<CommentEntity?>(null) }
var showCommentModal by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.initData() 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( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
bottomBar = { bottomBar = {
@@ -131,10 +186,9 @@ fun PostScreen(
} }
} }
}, },
onCreateComment = { onCreateCommentClick = {
scope.launch { replyComment = null
viewModel.createComment(it) showCommentModal = true
}
}, },
onFavoriteClick = { onFavoriteClick = {
scope.launch { scope.launch {
@@ -221,40 +275,26 @@ fun PostScreen(
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
OrderSelectionComponent() { OrderSelectionComponent() {
viewModel.order = it commentsViewModel.order = it
viewModel.reloadComment() viewModel.reloadComment()
} }
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
} }
item {
items(commentsPagging.itemCount) { idx -> CommentContent(
val item = commentsPagging[idx] ?: return@items viewModel = commentsViewModel,
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 = { onLongClick = {
if (AppState.UserId != item.id) {
return@CommentItem
}
showCommentMenu = true showCommentMenu = true
contextComment = item contextComment = it
},
onReply = { parentComment, _, _, _ ->
replyComment = parentComment
showCommentModal = true
} }
) )
} }
}
item { item {
Spacer(modifier = Modifier.height(72.dp)) 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun Header( fun Header(
@@ -526,8 +647,17 @@ fun CommentsSection(
@Composable @Composable
fun CommentItem( fun CommentItem(
commentEntity: CommentEntity, commentEntity: CommentEntity,
onLike: () -> Unit = {}, isChild: Boolean = false,
onLongClick: () -> Unit = {} 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 context = LocalContext.current
val navController = LocalNavController.current val navController = LocalNavController.current
@@ -544,7 +674,7 @@ fun CommentItem(
Row(modifier = Modifier.padding(vertical = 8.dp)) { Row(modifier = Modifier.padding(vertical = 8.dp)) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(40.dp) .size(if (isChild) 24.dp else 40.dp)
.background(Color.Gray.copy(alpha = 0.1f)) .background(Color.Gray.copy(alpha = 0.1f))
.noRippleClickable { .noRippleClickable {
navController.navigate( navController.navigate(
@@ -559,7 +689,7 @@ fun CommentItem(
context = context, context = context,
imageUrl = commentEntity.avatar, imageUrl = commentEntity.avatar,
contentDescription = "Comment Profile Picture", contentDescription = "Comment Profile Picture",
modifier = Modifier.size(40.dp), modifier = Modifier.size(if (isChild) 24.dp else 40.dp),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} }
@@ -568,12 +698,77 @@ fun CommentItem(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text(text = commentEntity.name, fontWeight = FontWeight.W600, fontSize = 14.sp) Text(text = commentEntity.name, fontWeight = FontWeight.W600, fontSize = 14.sp)
Text(text = commentEntity.comment, fontSize = 14.sp) 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(
text = commentEntity.date.timeAgo(context), text = commentEntity.date.timeAgo(context),
fontSize = 12.sp, fontSize = 12.sp,
color = Color.Gray 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)) Spacer(modifier = Modifier.width(16.dp))
Column( Column(
@@ -581,7 +776,9 @@ fun CommentItem(
) { ) {
AnimatedLikeIcon( AnimatedLikeIcon(
liked = commentEntity.liked, liked = commentEntity.liked,
onClick = onLike, onClick = {
onLike(commentEntity)
},
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
Text(text = commentEntity.likes.toString(), fontSize = 12.sp) Text(text = commentEntity.likes.toString(), fontSize = 12.sp)
@@ -589,11 +786,40 @@ fun CommentItem(
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Column( Column(
modifier = Modifier.padding(start = 16.dp) modifier = Modifier.padding(start = 12.dp + 40.dp)
) { ) {
commentEntity.replies.forEach { reply -> val addedCommentList =
CommentItem(reply) 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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PostBottomBar( fun PostBottomBar(
onCreateComment: (String) -> Unit = {}, onCreateCommentClick: () -> Unit = {},
onLikeClick: () -> Unit = {}, onLikeClick: () -> Unit = {},
onFavoriteClick: () -> Unit = {}, onFavoriteClick: () -> Unit = {},
momentEntity: MomentEntity? 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( Column(
modifier = Modifier.background(Color.White) modifier = Modifier.background(Color.White)
) { ) {
@@ -650,7 +859,7 @@ fun PostBottomBar(
.height(31.dp) .height(31.dp)
.padding(8.dp) .padding(8.dp)
.noRippleClickable { .noRippleClickable {
showCommentModal = true onCreateCommentClick()
} }
) { ) {
Row( Row(

View File

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