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,12 +2,9 @@ package com.qingshuige.tangyuan.ui.screens
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@ -19,29 +16,24 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.qingshuige.tangyuan.TangyuanApplication
import com.qingshuige.tangyuan.model.PostMetadata
import com.qingshuige.tangyuan.model.User
import com.qingshuige.tangyuan.model.PostCard
import com.qingshuige.tangyuan.ui.components.PostCardItem
import com.qingshuige.tangyuan.ui.components.ShimmerAsyncImage
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
import com.qingshuige.tangyuan.ui.theme.TangyuanGeneralFontFamily
import com.qingshuige.tangyuan.ui.theme.TangyuanShapes
import com.qingshuige.tangyuan.ui.theme.TangyuanTypography
import com.qingshuige.tangyuan.utils.withPanguSpacing
import com.qingshuige.tangyuan.viewmodel.UserDetailViewModel
import java.text.SimpleDateFormat
import java.util.*
/**
* 用户详情页
@ -55,6 +47,7 @@ fun UserDetailScreen(
onFollowClick: () -> Unit = {},
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null,
sharedElementPrefix: String? = null, // 从导航传递的前缀
viewModel: UserDetailViewModel = hiltViewModel()
) {
val user by viewModel.user.collectAsState()
@ -65,8 +58,9 @@ fun UserDetailScreen(
var isRefreshing by remember { mutableStateOf(false) }
// 初始加载
// 延迟初始加载以避免阻塞共享元素动画
LaunchedEffect(userId) {
kotlinx.coroutines.delay(200) // 等待共享元素动画完成
viewModel.loadUserDetails(userId)
}
@ -78,6 +72,16 @@ fun UserDetailScreen(
}
}
Scaffold(
topBar = {
UserDetailTopBar(
onBackClick = onBackClick,
userName = user?.nickName ?: "",
isLoading = isLoading
)
},
modifier = Modifier.fillMaxSize()
) { paddingValues ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
@ -85,54 +89,87 @@ fun UserDetailScreen(
viewModel.refreshUserData(userId)
isRefreshing = false
},
modifier = Modifier.fillMaxSize()
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 100.dp)
) {
// 顶部导航栏
item {
UserDetailTopBar(
onBackClick = onBackClick,
userName = user?.nickName ?: ""
)
}
// 用户信息卡片
// 用户信息区域
item {
if (isLoading) {
UserDetailLoadingCard()
UserProfileLoadingState()
} else {
user?.let { userInfo ->
UserDetailCard(
UserProfileSection(
user = userInfo,
onFollowClick = onFollowClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope
animatedContentScope = animatedContentScope,
sharedElementPrefix = sharedElementPrefix
)
}
}
}
// 统计信息
item {
// item {
// user?.let { userInfo ->
// UserStatsSection(
// postsCount = userPosts.size
// )
// }
// }
// 用户帖子信息流
user?.let { userInfo ->
UserStatsSection(
postsCount = userPosts.size,
user = userInfo
)
}
item {
PostsSectionHeader(postsCount = userPosts.size)
}
// 用户帖子列表
if (isPostsLoading && userPosts.isEmpty()) {
item {
PostsSection(
posts = userPosts,
isLoading = isPostsLoading,
onPostClick = onPostClick
PostsLoadingState()
}
} else if (userPosts.isEmpty() && !isPostsLoading) {
item {
EmptyPostsState()
}
} else {
// 使用PostCardItem展示完整帖子信息流
items(
items = userPosts,
key = { it.postId }
) { postCard ->
PostCardItem(
postCard = postCard,
onPostClick = onPostClick,
onAuthorClick = { /* 已经在用户详情页,不需要跳转 */ },
onLikeClick = { /* TODO: 实现点赞 */ },
onCommentClick = { /* TODO: 实现评论 */ },
onShareClick = { /* TODO: 实现分享 */ },
onBookmarkClick = { /* TODO: 实现收藏 */ },
onMoreClick = { /* TODO: 实现更多操作 */ },
onImageClick = { postId, imageIndex ->
// TODO: 实现图片点击
},
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope,
sharedElementPrefix = "userdetail_post_${postCard.postId}"
)
}
// 底部提示文字
if (userPosts.isNotEmpty()) {
item {
BottomIndicator()
}
}
}
}
}
}
}
}
@ -144,23 +181,33 @@ fun UserDetailScreen(
@Composable
private fun UserDetailTopBar(
onBackClick: () -> Unit,
userName: String
userName: String,
isLoading: Boolean = false
) {
TopAppBar(
title = {
if (isLoading) {
Text(
text = userName.withPanguSpacing(),
text = "用户详情",
style = MaterialTheme.typography.titleLarge,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurface
)
} else {
Text(
text = if (userName.isNotBlank()) "用户详情 · $userName" else "用户详情",
style = MaterialTheme.typography.titleLarge,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.Filled.ArrowBack,
imageVector = Icons.Default.ArrowBack,
contentDescription = "返回",
tint = MaterialTheme.colorScheme.onSurface
)
@ -182,94 +229,151 @@ private fun UserDetailTopBar(
}
/**
* 用户详情卡片
* 简洁的加载状态
*/
@Composable
private fun UserProfileLoadingState() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
strokeWidth = 3.dp,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "加载用户信息中...",
style = MaterialTheme.typography.bodyMedium,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* 无边界用户信息区域
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun UserDetailCard(
private fun UserProfileSection(
user: User,
onFollowClick: () -> Unit,
sharedTransitionScope: SharedTransitionScope?,
animatedContentScope: AnimatedContentScope?
animatedContentScope: AnimatedContentScope?,
sharedElementPrefix: String? = null
) {
Card(
var isVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isVisible = true
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = TangyuanShapes.CulturalCard,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(
defaultElevation = 4.dp
)
.padding(24.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
// 头像和昵称区域
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// 用户头像 - 支持共享元素动画
// 左侧头像 - 支持共享元素动画
ShimmerAsyncImage(
imageUrl = "${TangyuanApplication.instance.bizDomain}images/${user.avatarGuid}.jpg",
contentDescription = "${user.nickName}的头像",
modifier = Modifier
.size(120.dp)
.clip(CircleShape)
.size(80.dp)
.let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
mod.sharedElement(
rememberSharedContentState(key = "user_avatar_${user.userId}"),
rememberSharedContentState(
key = sharedElementPrefix?.let { "${it}_user_avatar_${user.userId}" }
?: "user_avatar_${user.userId}"
),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
tween(
durationMillis = 400,
easing = FastOutSlowInEasing
)
}
)
}
} else mod
},
}
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.width(20.dp))
// 用户名
// 右侧昵称和信息
Column(
modifier = Modifier.weight(1f)
) {
// 昵称 - 支持共享元素动画
Text(
text = user.nickName.withPanguSpacing(),
style = MaterialTheme.typography.headlineSmall,
fontFamily = TangyuanGeneralFontFamily,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
// 用户简介
if (user.bio.isNotBlank()) {
Text(
text = user.bio.withPanguSpacing(),
style = MaterialTheme.typography.bodyMedium,
fontFamily = LiteraryFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(16.dp))
modifier = if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
Modifier.sharedElement(
rememberSharedContentState(
key = sharedElementPrefix?.let { "${it}_user_name_${user.userId}" }
?: "user_name_${user.userId}"
),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else Modifier
)
// 地区信息
Spacer(modifier = Modifier.height(12.dp))
// 地区和邮箱信息 - 右侧划入动画
AnimatedVisibility(
visible = isVisible,
enter = slideInHorizontally(
initialOffsetX = { it },
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
) + fadeIn(
animationSpec = tween(400, delayMillis = 100)
)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// 地区信息胶囊
if (user.isoRegionName.isNotBlank()) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.wrapContentWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp)
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
) {
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = "地区",
tint = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.size(16.dp)
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
@ -280,34 +384,81 @@ private fun UserDetailCard(
)
}
}
}
// 邮箱信息胶囊
if (user.email.isNotBlank()) {
Surface(
shape = RoundedCornerShape(16.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.wrapContentWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
) {
Icon(
imageVector = Icons.Outlined.Email,
contentDescription = "邮箱",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = user.email,
style = MaterialTheme.typography.bodySmall,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
}
}
Spacer(modifier = Modifier.height(20.dp))
// 关注按钮
Button(
onClick = onFollowClick,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = MaterialTheme.shapes.small,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Outlined.PersonAdd,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
// 用户签名
if (user.bio.isNotBlank()) {
Text(
text = "关注",
style = MaterialTheme.typography.labelLarge,
fontFamily = TangyuanGeneralFontFamily,
fontWeight = FontWeight.Medium
text = user.bio.withPanguSpacing(),
style = MaterialTheme.typography.bodyMedium,
fontFamily = LiteraryFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(20.dp))
}
}
// // 关注按钮
// Button(
// onClick = onFollowClick,
// modifier = Modifier
// .fillMaxWidth()
// .height(48.dp),
// shape = RoundedCornerShape(12.dp),
// colors = ButtonDefaults.buttonColors(
// containerColor = MaterialTheme.colorScheme.primary
// )
// ) {
// Icon(
// imageVector = Icons.Outlined.PersonAdd,
// contentDescription = null,
// modifier = Modifier.size(18.dp)
// )
// Spacer(modifier = Modifier.width(8.dp))
// Text(
// text = "关注",
// style = MaterialTheme.typography.labelLarge,
// fontFamily = TangyuanGeneralFontFamily,
// fontWeight = FontWeight.Medium
// )
// }
}
}
@ -316,8 +467,7 @@ private fun UserDetailCard(
*/
@Composable
private fun UserStatsSection(
postsCount: Int,
user: User
postsCount: Int
) {
Card(
modifier = Modifier
@ -362,6 +512,128 @@ private fun UserStatsSection(
}
}
/**
* 帖子区域标题
*/
@Composable
private fun PostsSectionHeader(postsCount: Int) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "帖子",
style = MaterialTheme.typography.titleMedium,
fontFamily = TangyuanGeneralFontFamily,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "($postsCount)",
style = MaterialTheme.typography.titleMedium,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* 帖子加载状态
*/
@Composable
private fun PostsLoadingState() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "加载帖子中...",
style = MaterialTheme.typography.bodySmall,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* 空帖子状态
*/
@Composable
private fun EmptyPostsState() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Outlined.PostAdd,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "还没有发布过帖子",
style = MaterialTheme.typography.bodyMedium,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "期待 TA 的第一个分享",
style = MaterialTheme.typography.bodySmall,
fontFamily = LiteraryFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
)
}
}
/**
* 底部到底提示
*/
@Composable
private fun BottomIndicator() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
HorizontalDivider(
modifier = Modifier.fillMaxWidth(0.4f),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "已经到底了",
style = MaterialTheme.typography.bodySmall,
fontFamily = LiteraryFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "去发现更多精彩吧",
style = MaterialTheme.typography.bodySmall,
fontFamily = LiteraryFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
fontSize = 11.sp
)
}
}
/**
* 统计项组件
*/
@ -389,247 +661,27 @@ private fun StatItem(
}
}
/**
* 帖子列表区域
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Preview
@Composable
private fun PostsSection(
posts: List<PostMetadata>,
isLoading: Boolean,
onPostClick: (Int) -> Unit
) {
Column(
modifier = Modifier.padding(top = 16.dp)
) {
// 标题
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "发布的帖子",
style = MaterialTheme.typography.titleMedium,
fontFamily = TangyuanGeneralFontFamily,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
fun UserProfilePreview() {
val sampleUser = User(
userId = 1,
nickName = "示例用户",
avatarGuid = "sample_avatar",
bio = "这是一个示例用户的签名,用于展示用户详情卡片的样式。",
email = "123@example.com",
isoRegionName = "示例地区",
phoneNumber = "+1234567890",
password = "password",
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${posts.size}",
style = MaterialTheme.typography.bodySmall,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant
UserProfileSection(
user = sampleUser,
onFollowClick = {},
sharedTransitionScope = null,
animatedContentScope = null
)
}
if (isLoading) {
// 加载状态
repeat(3) {
PostItemSkeleton()
}
} else if (posts.isEmpty()) {
// 空状态
EmptyPostsState()
} else {
// 帖子列表
posts.forEach { post ->
UserPostItem(
post = post,
onClick = { onPostClick(post.postId) }
)
}
}
}
}
/**
* 用户帖子项
*/
@Composable
private fun UserPostItem(
post: PostMetadata,
onClick: () -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.clickable { onClick() },
shape = MaterialTheme.shapes.small,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(
defaultElevation = 1.dp
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = "帖子 #${post.postId}",
style = MaterialTheme.typography.titleSmall,
fontFamily = TangyuanGeneralFontFamily,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formatPostDate(post.postDateTime),
style = MaterialTheme.typography.bodySmall,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "查看详情",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
}
/**
* 加载骨架屏
*/
@Composable
private fun PostItemSkeleton() {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
shape = MaterialTheme.shapes.small
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Box(
modifier = Modifier
.fillMaxWidth(0.7f)
.height(16.dp)
.background(
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
RoundedCornerShape(4.dp)
)
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth(0.4f)
.height(12.dp)
.background(
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
RoundedCornerShape(4.dp)
)
)
}
}
}
}
/**
* 空状态
*/
@Composable
private fun EmptyPostsState() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Outlined.Article,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "还没有发布任何帖子",
style = MaterialTheme.typography.bodyMedium,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center
)
}
}
/**
* 用户详情加载卡片
*/
@Composable
private fun UserDetailLoadingCard() {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = TangyuanShapes.CulturalCard
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 头像占位
Box(
modifier = Modifier
.size(120.dp)
.clip(CircleShape)
.background(
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f)
)
)
Spacer(modifier = Modifier.height(16.dp))
// 用户名占位
Box(
modifier = Modifier
.width(120.dp)
.height(24.dp)
.background(
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
RoundedCornerShape(4.dp)
)
)
Spacer(modifier = Modifier.height(8.dp))
// 简介占位
Box(
modifier = Modifier
.width(200.dp)
.height(16.dp)
.background(
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.1f),
RoundedCornerShape(4.dp)
)
)
}
}
}
/**
* 格式化帖子日期
*/
private fun formatPostDate(date: Date?): String {
return date?.let {
val formatter = SimpleDateFormat("MM月dd日", Locale.getDefault())
formatter.format(it)
} ?: "未知时间"
}

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
}
}
@ -115,3 +132,99 @@ 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
)
}