refactor: Overhaul UserDetailScreen and enhance user post feed

This commit introduces a significant redesign of the `UserDetailScreen`, transforming it from a simple card-based layout to a more modern and refined profile view. It also replaces the basic list of user posts with a full-featured `PostCardItem` feed.

**Key Changes:**

*   **feat(UserDetailScreen):**
    *   Redesigned the user profile section with a cleaner, borderless layout, moving from a `Card` to a `Column`-based design.
    *   Enhanced user info display to include email and location as decorative "pills."
    *   Replaced the previous list of post metadata with a full `PostCardItem`-based feed, showing complete post content directly on the user's profile.
    *   Added loading skeletons for the profile and post list, as well as an improved empty state for users with no posts.
    *   Added a "bottom indicator" to signify the end of the post list.

*   **refactor(ViewModel):**
    *   Modified `UserDetailViewModel` to fetch and construct full `PostCard` objects for the user's posts, instead of just `PostMetadata`.
    *   This involves fetching `PostBody` and `Category` details for each post to provide a rich feed experience.

*   **refactor(Shared Element Transition):**
    *   Introduced `sharedElementPrefix` to `PostCardItem` and `UserDetailScreen` to create unique transition keys for elements (like avatars and names) that appear in multiple screens (e.g., `talk_post_...`, `userdetail_post_...`).
    *   This ensures that shared element animations are correctly scoped and avoids conflicts when navigating between different feeds and detail screens.
    *   The shared element transition for user avatar and name now works correctly from any `PostCard` to the `UserDetailScreen`.

*   **feat(Repository):**
    *   Added `getPostBody(postId)` and `getCategory(categoryId)` methods to `UserRepository` to support fetching the detailed data required for constructing `PostCard` objects.
This commit is contained in:
grtsinry43 2025-10-06 13:13:47 +08:00
parent 39b5c3e40f
commit 0a0491ca1b
Signed by: grtsinry43
GPG Key ID: F3305FB3A978C934
10 changed files with 763 additions and 440 deletions

View File

@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-10-05T14:23:49.203872Z">
<DropdownSelection timestamp="2025-10-06T04:37:55.083829Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=6fbe7ac" />

View File

@ -44,6 +44,9 @@ fun App() {
onImageClick = { postId, imageIndex ->
navController.navigate(Screen.ImageDetail.createRoute(postId, imageIndex))
},
onAuthorClick = { authorId ->
navController.navigate(Screen.UserDetail.createRoute(authorId))
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable
)
@ -173,6 +176,7 @@ fun MainFlow(
onLoginClick: () -> Unit,
onPostClick: (Int) -> Unit,
onImageClick: (Int, Int) -> Unit = { _, _ -> },
onAuthorClick: (Int) -> Unit = {},
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null
) {
@ -216,9 +220,7 @@ fun MainFlow(
composable(Screen.Talk.route) {
TalkScreen(
onPostClick = onPostClick,
onAuthorClick = { authorId ->
// TODO: 导航到用户详情页
},
onAuthorClick = onAuthorClick,
onImageClick = onImageClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope

View File

@ -4,6 +4,8 @@ import com.qingshuige.tangyuan.api.ApiInterface
import com.qingshuige.tangyuan.model.CreateUserDto
import com.qingshuige.tangyuan.model.LoginDto
import com.qingshuige.tangyuan.model.User
import com.qingshuige.tangyuan.model.PostBody
import com.qingshuige.tangyuan.model.Category
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import retrofit2.awaitResponse
@ -72,4 +74,24 @@ class UserRepository @Inject constructor(
throw Exception("Failed to get user posts: ${response.message()}")
}
}
fun getPostBody(postId: Int): Flow<PostBody> = flow {
val response = apiInterface.getPostBody(postId).awaitResponse()
if (response.isSuccessful) {
response.body()?.let { emit(it) }
?: throw Exception("Post body not found")
} else {
throw Exception("Failed to get post body: ${response.message()}")
}
}
fun getCategory(categoryId: Int): Flow<Category> = flow {
val response = apiInterface.getCategory(categoryId).awaitResponse()
if (response.isSuccessful) {
response.body()?.let { emit(it) }
?: throw Exception("Category not found")
} else {
throw Exception("Failed to get category: ${response.message()}")
}
}
}

View File

@ -36,12 +36,17 @@ import com.qingshuige.tangyuan.utils.withPanguSpacing
/**
* 评论项组件
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun CommentItem(
comment: CommentCard,
onReplyToComment: (CommentCard) -> Unit = {},
onDeleteComment: (Int) -> Unit = {},
modifier: Modifier = Modifier
onAuthorClick: (Int) -> Unit = {},
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null,
sharedElementPrefix: String = "comment"
) {
Card(
modifier = modifier
@ -62,7 +67,11 @@ fun CommentItem(
CommentMainContent(
comment = comment,
onReplyToComment = onReplyToComment,
onDeleteComment = onDeleteComment
onDeleteComment = onDeleteComment,
onAuthorClick = onAuthorClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope,
sharedElementPrefix = sharedElementPrefix
)
// 回复列表
@ -81,15 +90,26 @@ fun CommentItem(
/**
* 评论主体内容
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun CommentMainContent(
comment: CommentCard,
onReplyToComment: (CommentCard) -> Unit,
onDeleteComment: (Int) -> Unit
onDeleteComment: (Int) -> Unit,
onAuthorClick: (Int) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null,
sharedElementPrefix: String = "comment"
) {
Column {
// 评论头部 - 用户信息
CommentHeader(comment = comment)
CommentHeader(
comment = comment,
onAuthorClick = onAuthorClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope,
sharedElementPrefix = sharedElementPrefix
)
Spacer(modifier = Modifier.height(8.dp))
@ -123,10 +143,18 @@ private fun CommentMainContent(
/**
* 评论头部 - 用户信息
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun CommentHeader(comment: CommentCard) {
private fun CommentHeader(
comment: CommentCard,
onAuthorClick: (Int) -> Unit = {},
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null,
sharedElementPrefix: String = "comment"
) {
Row(
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { onAuthorClick(comment.authorId) }
) {
// 用户头像
AsyncImage(
@ -137,6 +165,19 @@ private fun CommentHeader(comment: CommentCard) {
contentDescription = "${comment.authorName}的头像",
modifier = Modifier
.size(32.dp)
.let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
mod.sharedElement(
rememberSharedContentState(key = "${sharedElementPrefix}_user_avatar_${comment.authorId}"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else mod
}
.clip(CircleShape),
contentScale = ContentScale.Crop
)
@ -154,7 +195,18 @@ private fun CommentHeader(comment: CommentCard) {
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
rememberSharedContentState(key = "${sharedElementPrefix}_user_name_${comment.authorId}"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else Modifier
)
}

View File

@ -136,7 +136,8 @@ fun PostCardItem(
onImageClick: (Int, Int) -> Unit = { _, _ -> },
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null
animatedContentScope: AnimatedContentScope? = null,
sharedElementPrefix: String = "postcard" // 添加前缀来区分不同位置的头像
) {
Card(
modifier = modifier
@ -176,7 +177,8 @@ fun PostCardItem(
onAuthorClick = onAuthorClick,
onMoreClick = onMoreClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope
animatedContentScope = animatedContentScope,
sharedElementPrefix = sharedElementPrefix
)
Spacer(modifier = Modifier.height(12.dp))
@ -225,7 +227,8 @@ private fun PostCardHeader(
onAuthorClick: (Int) -> Unit,
onMoreClick: (Int) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null
animatedContentScope: AnimatedContentScope? = null,
sharedElementPrefix: String = "postcard"
) {
Row(
modifier = Modifier.fillMaxWidth(),
@ -237,12 +240,11 @@ private fun PostCardHeader(
contentDescription = "${postCard.authorName}的头像",
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
mod.sharedElement(
rememberSharedContentState(key = "user_avatar_${postCard.authorId}"),
rememberSharedContentState(key = "${sharedElementPrefix}_user_avatar_${postCard.authorId}"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
@ -250,7 +252,8 @@ private fun PostCardHeader(
)
}
} else mod
},
}
.clip(CircleShape),
contentScale = ContentScale.Crop,
onClick = { onAuthorClick(postCard.authorId) }
)
@ -269,7 +272,18 @@ private fun PostCardHeader(
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
overflow = TextOverflow.Ellipsis,
modifier = if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
rememberSharedContentState(key = "${sharedElementPrefix}_user_name_${postCard.authorId}"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else Modifier
)
Text(

View File

@ -113,7 +113,9 @@ fun ImageDetailScreen(
BottomContentOverlay(
postCard = postCard,
onAuthorClick = onAuthorClick,
onSwitchToTextMode = onSwitchToTextMode
onSwitchToTextMode = onSwitchToTextMode,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope
)
}
}
@ -313,11 +315,14 @@ private fun ZoomableImage(
/**
* 底部内容遮罩
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun BottomContentOverlay(
postCard: PostCard,
onAuthorClick: (Int) -> Unit,
onSwitchToTextMode: () -> Unit
onSwitchToTextMode: () -> Unit,
sharedTransitionScope: SharedTransitionScope?,
animatedContentScope: AnimatedContentScope?
) {
var offsetY by remember { mutableFloatStateOf(0f) }
val swipeThreshold = -100f // 上滑超过100px触发切换
@ -356,7 +361,9 @@ private fun BottomContentOverlay(
// 作者信息
PostAuthorInfo(
postCard = postCard,
onAuthorClick = onAuthorClick
onAuthorClick = onAuthorClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope
)
Spacer(modifier = Modifier.height(16.dp))
@ -424,10 +431,13 @@ private fun BottomContentOverlay(
/**
* 作者信息组件
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun PostAuthorInfo(
postCard: PostCard,
onAuthorClick: (Int) -> Unit
onAuthorClick: (Int) -> Unit,
sharedTransitionScope: SharedTransitionScope?,
animatedContentScope: AnimatedContentScope?
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@ -441,6 +451,19 @@ private fun PostAuthorInfo(
contentDescription = "${postCard.authorName}的头像",
modifier = Modifier
.size(48.dp)
.let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
mod.sharedElement(
rememberSharedContentState(key = "user_avatar_${postCard.authorId}"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else mod
}
.clip(CircleShape),
contentScale = ContentScale.Crop
)
@ -453,7 +476,18 @@ private fun PostAuthorInfo(
style = MaterialTheme.typography.titleMedium,
fontFamily = TangyuanGeneralFontFamily,
color = Color.White,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
modifier = if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
rememberSharedContentState(key = "user_name_${postCard.authorId}"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else Modifier
)
if (postCard.authorBio.isNotBlank()) {

View File

@ -234,7 +234,11 @@ private fun PostDetailContent(
CommentItem(
comment = comment,
onReplyToComment = onReplyToComment,
onDeleteComment = onDeleteComment
onDeleteComment = onDeleteComment,
onAuthorClick = onAuthorClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope,
sharedElementPrefix = "postdetail_comment_${comment.commentId}"
)
}
@ -291,7 +295,9 @@ private fun PostDetailCard(
// 作者信息
PostDetailHeader(
postCard = postCard,
onAuthorClick = onAuthorClick
onAuthorClick = onAuthorClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope
)
Spacer(modifier = Modifier.height(16.dp))
@ -354,10 +360,13 @@ private fun PostDetailCard(
/**
* 帖子详情头部
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun PostDetailHeader(
postCard: PostCard,
onAuthorClick: (Int) -> Unit
onAuthorClick: (Int) -> Unit,
sharedTransitionScope: SharedTransitionScope?,
animatedContentScope: AnimatedContentScope?
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@ -371,6 +380,19 @@ private fun PostDetailHeader(
contentDescription = "${postCard.authorName}的头像",
modifier = Modifier
.size(48.dp)
.let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
mod.sharedElement(
rememberSharedContentState(key = "user_avatar_${postCard.authorId}"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else mod
}
.clip(CircleShape),
contentScale = ContentScale.Crop
)
@ -383,7 +405,18 @@ private fun PostDetailHeader(
style = MaterialTheme.typography.titleMedium,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
modifier = if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
rememberSharedContentState(key = "user_name_${postCard.authorId}"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else Modifier
)
if (postCard.authorBio.isNotBlank()) {

View File

@ -156,7 +156,8 @@ private fun PostList(
onMoreClick = onMoreClick,
onImageClick = onImageClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope
animatedContentScope = animatedContentScope,
sharedElementPrefix = "talk_post_${postCard.postId}" // 使用帖子ID作为唯一前缀
)
}

View File

@ -2,8 +2,11 @@ package com.qingshuige.tangyuan.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.qingshuige.tangyuan.model.Category
import com.qingshuige.tangyuan.model.PostBody
import com.qingshuige.tangyuan.model.PostMetadata
import com.qingshuige.tangyuan.model.User
import com.qingshuige.tangyuan.model.PostCard
import com.qingshuige.tangyuan.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@ -11,6 +14,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import javax.inject.Inject
@HiltViewModel
@ -22,9 +26,9 @@ class UserDetailViewModel @Inject constructor(
private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user.asStateFlow()
// 用户帖子列表状态
private val _userPosts = MutableStateFlow<List<PostMetadata>>(emptyList())
val userPosts: StateFlow<List<PostMetadata>> = _userPosts.asStateFlow()
// 用户帖子列表状态 - 改为PostCard列表
private val _userPosts = MutableStateFlow<List<PostCard>>(emptyList())
val userPosts: StateFlow<List<PostCard>> = _userPosts.asStateFlow()
// 加载状态
private val _isLoading = MutableStateFlow(false)
@ -55,15 +59,15 @@ class UserDetailViewModel @Inject constructor(
_user.value = userInfo
_isLoading.value = false
// 获取用户信息成功后,加载用户的帖子
loadUserPosts(userId)
loadUserPosts(userId, userInfo)
}
}
}
/**
* 加载用户的帖子列表
* 加载用户的帖子列表包含完整的PostCard信息
*/
private fun loadUserPosts(userId: Int) {
private fun loadUserPosts(userId: Int, user: User) {
viewModelScope.launch {
_isPostsLoading.value = true
@ -73,7 +77,20 @@ class UserDetailViewModel @Inject constructor(
_isPostsLoading.value = false
}
.collect { posts ->
_userPosts.value = posts
// 并行获取每个帖子的完整信息
val postCards = posts.map { postMetadata ->
async {
try {
postMetadata.toPostCard(user, userRepository)
} catch (e: Exception) {
// 如果获取详细信息失败,返回简化版本
postMetadata.toSimplePostCard(user)
}
}
}.map { it.await() }
// 按时间倒序排序,新的在前面
_userPosts.value = postCards.sortedByDescending { it.postDateTime }
_isPostsLoading.value = false
}
}
@ -114,4 +131,100 @@ class UserDetailViewModel @Inject constructor(
}
}
}
}
/**
* PostMetadata扩展函数转换为完整的PostCard
*/
private suspend fun PostMetadata.toPostCard(
author: User,
userRepository: UserRepository
): PostCard = kotlinx.coroutines.coroutineScope {
return@coroutineScope try {
// 并行获取PostBody和Category
val postBodyDeferred = async {
var result: PostBody? = null
userRepository.getPostBody(this@toPostCard.postId)
.catch { /* 忽略错误使用默认null值 */ }
.collect { result = it }
result
}
val categoryDeferred = async {
var result: Category? = null
userRepository.getCategory(this@toPostCard.categoryId)
.catch { /* 忽略错误使用默认null值 */ }
.collect { result = it }
result
}
val postBody = postBodyDeferred.await()
val category = categoryDeferred.await()
// 提取图片UUID列表
val imageUUIDs = listOfNotNull(
postBody?.image1UUID,
postBody?.image2UUID,
postBody?.image3UUID
).filter { it.isNotBlank() }
PostCard(
postId = this@toPostCard.postId,
postDateTime = this@toPostCard.postDateTime,
isVisible = this@toPostCard.isVisible,
authorId = author.userId,
authorName = author.nickName.ifBlank { "匿名用户" },
authorAvatar = author.avatarGuid,
authorBio = author.bio,
categoryId = this@toPostCard.categoryId,
categoryName = category?.baseName ?: "未分类",
categoryDescription = category?.baseDescription ?: "",
textContent = postBody?.textContent ?: "内容获取失败",
imageUUIDs = imageUUIDs,
hasImages = imageUUIDs.isNotEmpty(),
// 默认互动数据
likeCount = 0,
commentCount = 0,
shareCount = 0,
isLiked = false,
isBookmarked = false
)
} catch (e: Exception) {
// 如果获取详细信息失败,返回简化版本
this@toPostCard.toSimplePostCard(author)
}
}
/**
* PostMetadata扩展函数转换为简化的PostCard作为备用
*/
private fun PostMetadata.toSimplePostCard(author: User): PostCard {
return PostCard(
postId = this.postId,
postDateTime = this.postDateTime,
isVisible = this.isVisible,
authorId = author.userId,
authorName = author.nickName.ifBlank { "匿名用户" },
authorAvatar = author.avatarGuid,
authorBio = author.bio,
categoryId = this.categoryId,
categoryName = "分类 ${this.categoryId}", // 简化显示
categoryDescription = "",
textContent = "点击查看完整内容...",
imageUUIDs = emptyList(),
hasImages = false,
// 默认互动数据
likeCount = 0,
commentCount = 0,
shareCount = 0,
isLiked = false,
isBookmarked = false
)
}