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> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <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"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=6fbe7ac" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=6fbe7ac" />

View File

@ -44,6 +44,9 @@ fun App() {
onImageClick = { postId, imageIndex -> onImageClick = { postId, imageIndex ->
navController.navigate(Screen.ImageDetail.createRoute(postId, imageIndex)) navController.navigate(Screen.ImageDetail.createRoute(postId, imageIndex))
}, },
onAuthorClick = { authorId ->
navController.navigate(Screen.UserDetail.createRoute(authorId))
},
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable animatedContentScope = this@composable
) )
@ -173,6 +176,7 @@ fun MainFlow(
onLoginClick: () -> Unit, onLoginClick: () -> Unit,
onPostClick: (Int) -> Unit, onPostClick: (Int) -> Unit,
onImageClick: (Int, Int) -> Unit = { _, _ -> }, onImageClick: (Int, Int) -> Unit = { _, _ -> },
onAuthorClick: (Int) -> Unit = {},
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null animatedContentScope: AnimatedContentScope? = null
) { ) {
@ -216,9 +220,7 @@ fun MainFlow(
composable(Screen.Talk.route) { composable(Screen.Talk.route) {
TalkScreen( TalkScreen(
onPostClick = onPostClick, onPostClick = onPostClick,
onAuthorClick = { authorId -> onAuthorClick = onAuthorClick,
// TODO: 导航到用户详情页
},
onImageClick = onImageClick, onImageClick = onImageClick,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope 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.CreateUserDto
import com.qingshuige.tangyuan.model.LoginDto import com.qingshuige.tangyuan.model.LoginDto
import com.qingshuige.tangyuan.model.User 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 kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import retrofit2.awaitResponse import retrofit2.awaitResponse
@ -72,4 +74,24 @@ class UserRepository @Inject constructor(
throw Exception("Failed to get user posts: ${response.message()}") 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 @Composable
fun CommentItem( fun CommentItem(
comment: CommentCard, comment: CommentCard,
onReplyToComment: (CommentCard) -> Unit = {}, onReplyToComment: (CommentCard) -> Unit = {},
onDeleteComment: (Int) -> Unit = {}, onDeleteComment: (Int) -> Unit = {},
modifier: Modifier = Modifier onAuthorClick: (Int) -> Unit = {},
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null,
sharedElementPrefix: String = "comment"
) { ) {
Card( Card(
modifier = modifier modifier = modifier
@ -62,7 +67,11 @@ fun CommentItem(
CommentMainContent( CommentMainContent(
comment = comment, comment = comment,
onReplyToComment = onReplyToComment, onReplyToComment = onReplyToComment,
onDeleteComment = onDeleteComment onDeleteComment = onDeleteComment,
onAuthorClick = onAuthorClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope,
sharedElementPrefix = sharedElementPrefix
) )
// 回复列表 // 回复列表
@ -81,15 +90,26 @@ fun CommentItem(
/** /**
* 评论主体内容 * 评论主体内容
*/ */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun CommentMainContent( private fun CommentMainContent(
comment: CommentCard, comment: CommentCard,
onReplyToComment: (CommentCard) -> Unit, onReplyToComment: (CommentCard) -> Unit,
onDeleteComment: (Int) -> Unit onDeleteComment: (Int) -> Unit,
onAuthorClick: (Int) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null,
sharedElementPrefix: String = "comment"
) { ) {
Column { Column {
// 评论头部 - 用户信息 // 评论头部 - 用户信息
CommentHeader(comment = comment) CommentHeader(
comment = comment,
onAuthorClick = onAuthorClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope,
sharedElementPrefix = sharedElementPrefix
)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -123,10 +143,18 @@ private fun CommentMainContent(
/** /**
* 评论头部 - 用户信息 * 评论头部 - 用户信息
*/ */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun CommentHeader(comment: CommentCard) { private fun CommentHeader(
comment: CommentCard,
onAuthorClick: (Int) -> Unit = {},
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null,
sharedElementPrefix: String = "comment"
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { onAuthorClick(comment.authorId) }
) { ) {
// 用户头像 // 用户头像
AsyncImage( AsyncImage(
@ -137,6 +165,19 @@ private fun CommentHeader(comment: CommentCard) {
contentDescription = "${comment.authorName}的头像", contentDescription = "${comment.authorName}的头像",
modifier = Modifier modifier = Modifier
.size(32.dp) .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), .clip(CircleShape),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
@ -154,7 +195,18 @@ private fun CommentHeader(comment: CommentCard) {
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, 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 = { _, _ -> }, onImageClick: (Int, Int) -> Unit = { _, _ -> },
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null animatedContentScope: AnimatedContentScope? = null,
sharedElementPrefix: String = "postcard" // 添加前缀来区分不同位置的头像
) { ) {
Card( Card(
modifier = modifier modifier = modifier
@ -176,7 +177,8 @@ fun PostCardItem(
onAuthorClick = onAuthorClick, onAuthorClick = onAuthorClick,
onMoreClick = onMoreClick, onMoreClick = onMoreClick,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope animatedContentScope = animatedContentScope,
sharedElementPrefix = sharedElementPrefix
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@ -225,7 +227,8 @@ private fun PostCardHeader(
onAuthorClick: (Int) -> Unit, onAuthorClick: (Int) -> Unit,
onMoreClick: (Int) -> Unit, onMoreClick: (Int) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null animatedContentScope: AnimatedContentScope? = null,
sharedElementPrefix: String = "postcard"
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -237,12 +240,11 @@ private fun PostCardHeader(
contentDescription = "${postCard.authorName}的头像", contentDescription = "${postCard.authorName}的头像",
modifier = Modifier modifier = Modifier
.size(40.dp) .size(40.dp)
.clip(CircleShape)
.let { mod -> .let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) { if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) { with(sharedTransitionScope) {
mod.sharedElement( mod.sharedElement(
rememberSharedContentState(key = "user_avatar_${postCard.authorId}"), rememberSharedContentState(key = "${sharedElementPrefix}_user_avatar_${postCard.authorId}"),
animatedVisibilityScope = animatedContentScope, animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ -> boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing) tween(durationMillis = 400, easing = FastOutSlowInEasing)
@ -250,7 +252,8 @@ private fun PostCardHeader(
) )
} }
} else mod } else mod
}, }
.clip(CircleShape),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
onClick = { onAuthorClick(postCard.authorId) } onClick = { onAuthorClick(postCard.authorId) }
) )
@ -269,7 +272,18 @@ private fun PostCardHeader(
fontFamily = TangyuanGeneralFontFamily, fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, 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( Text(

View File

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

View File

@ -234,7 +234,11 @@ private fun PostDetailContent(
CommentItem( CommentItem(
comment = comment, comment = comment,
onReplyToComment = onReplyToComment, 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( PostDetailHeader(
postCard = postCard, postCard = postCard,
onAuthorClick = onAuthorClick onAuthorClick = onAuthorClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@ -354,10 +360,13 @@ private fun PostDetailCard(
/** /**
* 帖子详情头部 * 帖子详情头部
*/ */
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun PostDetailHeader( private fun PostDetailHeader(
postCard: PostCard, postCard: PostCard,
onAuthorClick: (Int) -> Unit onAuthorClick: (Int) -> Unit,
sharedTransitionScope: SharedTransitionScope?,
animatedContentScope: AnimatedContentScope?
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -371,6 +380,19 @@ private fun PostDetailHeader(
contentDescription = "${postCard.authorName}的头像", contentDescription = "${postCard.authorName}的头像",
modifier = Modifier modifier = Modifier
.size(48.dp) .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), .clip(CircleShape),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
@ -383,7 +405,18 @@ private fun PostDetailHeader(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontFamily = TangyuanGeneralFontFamily, fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurface, 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()) { if (postCard.authorBio.isNotBlank()) {

View File

@ -156,7 +156,8 @@ private fun PostList(
onMoreClick = onMoreClick, onMoreClick = onMoreClick,
onImageClick = onImageClick, onImageClick = onImageClick,
sharedTransitionScope = sharedTransitionScope, 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.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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.material.icons.Icons import androidx.compose.material.icons.Icons
@ -19,29 +16,24 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
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.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.qingshuige.tangyuan.TangyuanApplication import com.qingshuige.tangyuan.TangyuanApplication
import com.qingshuige.tangyuan.model.PostMetadata import com.qingshuige.tangyuan.model.PostMetadata
import com.qingshuige.tangyuan.model.User 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.components.ShimmerAsyncImage
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
import com.qingshuige.tangyuan.ui.theme.TangyuanGeneralFontFamily 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.ui.theme.TangyuanTypography
import com.qingshuige.tangyuan.utils.withPanguSpacing import com.qingshuige.tangyuan.utils.withPanguSpacing
import com.qingshuige.tangyuan.viewmodel.UserDetailViewModel import com.qingshuige.tangyuan.viewmodel.UserDetailViewModel
import java.text.SimpleDateFormat
import java.util.*
/** /**
* 用户详情页 * 用户详情页
@ -55,6 +47,7 @@ fun UserDetailScreen(
onFollowClick: () -> Unit = {}, onFollowClick: () -> Unit = {},
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null, animatedContentScope: AnimatedContentScope? = null,
sharedElementPrefix: String? = null, // 从导航传递的前缀
viewModel: UserDetailViewModel = hiltViewModel() viewModel: UserDetailViewModel = hiltViewModel()
) { ) {
val user by viewModel.user.collectAsState() val user by viewModel.user.collectAsState()
@ -65,8 +58,9 @@ fun UserDetailScreen(
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
// 初始加载 // 延迟初始加载以避免阻塞共享元素动画
LaunchedEffect(userId) { LaunchedEffect(userId) {
kotlinx.coroutines.delay(200) // 等待共享元素动画完成
viewModel.loadUserDetails(userId) viewModel.loadUserDetails(userId)
} }
@ -78,60 +72,103 @@ fun UserDetailScreen(
} }
} }
PullToRefreshBox( Scaffold(
isRefreshing = isRefreshing, topBar = {
onRefresh = { UserDetailTopBar(
isRefreshing = true onBackClick = onBackClick,
viewModel.refreshUserData(userId) userName = user?.nickName ?: "",
isRefreshing = false isLoading = isLoading
)
}, },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) { paddingValues ->
LazyColumn( PullToRefreshBox(
modifier = Modifier.fillMaxSize(), isRefreshing = isRefreshing,
contentPadding = PaddingValues(bottom = 100.dp) onRefresh = {
isRefreshing = true
viewModel.refreshUserData(userId)
isRefreshing = false
},
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) { ) {
// 顶部导航栏 LazyColumn(
item { modifier = Modifier.fillMaxSize(),
UserDetailTopBar( contentPadding = PaddingValues(bottom = 100.dp)
onBackClick = onBackClick, ) {
userName = user?.nickName ?: "" // 用户信息区域
) item {
} if (isLoading) {
UserProfileLoadingState()
// 用户信息卡片 } else {
item { user?.let { userInfo ->
if (isLoading) { UserProfileSection(
UserDetailLoadingCard() user = userInfo,
} else { onFollowClick = onFollowClick,
user?.let { userInfo -> sharedTransitionScope = sharedTransitionScope,
UserDetailCard( animatedContentScope = animatedContentScope,
user = userInfo, sharedElementPrefix = sharedElementPrefix
onFollowClick = onFollowClick, )
sharedTransitionScope = sharedTransitionScope, }
animatedContentScope = animatedContentScope
)
} }
} }
}
// 统计信息 // 统计信息
item { // item {
// user?.let { userInfo ->
// UserStatsSection(
// postsCount = userPosts.size
// )
// }
// }
// 用户帖子信息流
user?.let { userInfo -> user?.let { userInfo ->
UserStatsSection( item {
postsCount = userPosts.size, PostsSectionHeader(postsCount = userPosts.size)
user = userInfo }
)
}
}
// 用户帖子列表 if (isPostsLoading && userPosts.isEmpty()) {
item { item {
PostsSection( PostsLoadingState()
posts = userPosts, }
isLoading = isPostsLoading, } else if (userPosts.isEmpty() && !isPostsLoading) {
onPostClick = onPostClick 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 @Composable
private fun UserDetailTopBar( private fun UserDetailTopBar(
onBackClick: () -> Unit, onBackClick: () -> Unit,
userName: String userName: String,
isLoading: Boolean = false
) { ) {
TopAppBar( TopAppBar(
title = { title = {
Text( if (isLoading) {
text = userName.withPanguSpacing(), Text(
style = MaterialTheme.typography.titleLarge, text = "用户详情",
fontFamily = TangyuanGeneralFontFamily, style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface, fontFamily = TangyuanGeneralFontFamily,
maxLines = 1, color = MaterialTheme.colorScheme.onSurface
overflow = TextOverflow.Ellipsis )
) } else {
Text(
text = if (userName.isNotBlank()) "用户详情 · $userName" else "用户详情",
style = MaterialTheme.typography.titleLarge,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBackClick) { IconButton(onClick = onBackClick) {
Icon( Icon(
imageVector = Icons.Filled.ArrowBack, imageVector = Icons.Default.ArrowBack,
contentDescription = "返回", contentDescription = "返回",
tint = MaterialTheme.colorScheme.onSurface tint = MaterialTheme.colorScheme.onSurface
) )
@ -182,132 +229,236 @@ 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) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun UserDetailCard( private fun UserProfileSection(
user: User, user: User,
onFollowClick: () -> Unit, onFollowClick: () -> Unit,
sharedTransitionScope: SharedTransitionScope?, sharedTransitionScope: SharedTransitionScope?,
animatedContentScope: AnimatedContentScope? animatedContentScope: AnimatedContentScope?,
sharedElementPrefix: String? = null
) { ) {
Card( var isVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
isVisible = true
}
Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(24.dp)
shape = TangyuanShapes.CulturalCard,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(
defaultElevation = 4.dp
)
) { ) {
Column( // 头像和昵称区域
modifier = Modifier.padding(24.dp), Row(
horizontalAlignment = Alignment.CenterHorizontally modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) { ) {
// 用户头像 - 支持共享元素动画 // 左侧头像 - 支持共享元素动画
ShimmerAsyncImage( ShimmerAsyncImage(
imageUrl = "${TangyuanApplication.instance.bizDomain}images/${user.avatarGuid}.jpg", imageUrl = "${TangyuanApplication.instance.bizDomain}images/${user.avatarGuid}.jpg",
contentDescription = "${user.nickName}的头像", contentDescription = "${user.nickName}的头像",
modifier = Modifier modifier = Modifier
.size(120.dp) .size(80.dp)
.clip(CircleShape)
.let { mod -> .let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) { if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) { with(sharedTransitionScope) {
mod.sharedElement( mod.sharedElement(
rememberSharedContentState(key = "user_avatar_${user.userId}"), rememberSharedContentState(
key = sharedElementPrefix?.let { "${it}_user_avatar_${user.userId}" }
?: "user_avatar_${user.userId}"
),
animatedVisibilityScope = animatedContentScope, animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ -> boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing) tween(
durationMillis = 400,
easing = FastOutSlowInEasing
)
} }
) )
} }
} else mod } else mod
}, }
.clip(CircleShape),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.width(20.dp))
// 用户名 // 右侧昵称和信息
Text( Column(
text = user.nickName.withPanguSpacing(), modifier = Modifier.weight(1f)
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(
text = user.bio.withPanguSpacing(), text = user.nickName.withPanguSpacing(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.headlineSmall,
fontFamily = LiteraryFontFamily, fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurface,
lineHeight = 22.sp 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(16.dp)) Spacer(modifier = Modifier.height(12.dp))
}
// 地区信息 // 地区和邮箱信息 - 右侧划入动画
if (user.isoRegionName.isNotBlank()) { AnimatedVisibility(
Row( visible = isVisible,
verticalAlignment = Alignment.CenterVertically, enter = slideInHorizontally(
modifier = Modifier.padding(vertical = 4.dp) initialOffsetX = { it },
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
) + fadeIn(
animationSpec = tween(400, delayMillis = 100)
)
) { ) {
Icon( Row(
imageVector = Icons.Outlined.LocationOn, horizontalArrangement = Arrangement.spacedBy(8.dp)
contentDescription = "地区", ) {
tint = MaterialTheme.colorScheme.tertiary, // 地区信息胶囊
modifier = Modifier.size(16.dp) if (user.isoRegionName.isNotBlank()) {
) Surface(
Spacer(modifier = Modifier.width(4.dp)) shape = RoundedCornerShape(16.dp),
Text( color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
text = user.isoRegionName, modifier = Modifier.wrapContentWidth()
style = MaterialTheme.typography.bodySmall, ) {
fontFamily = TangyuanGeneralFontFamily, Row(
color = MaterialTheme.colorScheme.onSurfaceVariant verticalAlignment = Alignment.CenterVertically,
) modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
) {
Icon(
imageVector = Icons.Outlined.LocationOn,
contentDescription = "地区",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = user.isoRegionName,
style = MaterialTheme.typography.bodySmall,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// 邮箱信息胶囊
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))
// 用户签名
if (user.bio.isNotBlank()) {
Text(
text = user.bio.withPanguSpacing(),
style = MaterialTheme.typography.bodyMedium,
fontFamily = LiteraryFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(20.dp)) 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))
Text(
text = "关注",
style = MaterialTheme.typography.labelLarge,
fontFamily = TangyuanGeneralFontFamily,
fontWeight = FontWeight.Medium
)
}
} }
// // 关注按钮
// 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 @Composable
private fun UserStatsSection( private fun UserStatsSection(
postsCount: Int, postsCount: Int
user: User
) { ) {
Card( Card(
modifier = Modifier modifier = Modifier
@ -346,7 +496,7 @@ private fun UserStatsSection(
StatItem( StatItem(
label = "关注", label = "关注",
value = "0" // TODO: 从API获取关注数 value = "0" // TODO: 从 API 获取关注数
) )
VerticalDivider( VerticalDivider(
@ -356,12 +506,134 @@ private fun UserStatsSection(
StatItem( StatItem(
label = "粉丝", label = "粉丝",
value = "0" // TODO: 从API获取粉丝数 value = "0" // TODO: 从 API 获取粉丝数
) )
} }
} }
} }
/**
* 帖子区域标题
*/
@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 @Composable
private fun PostsSection( fun UserProfilePreview() {
posts: List<PostMetadata>, val sampleUser = User(
isLoading: Boolean, userId = 1,
onPostClick: (Int) -> Unit nickName = "示例用户",
) { avatarGuid = "sample_avatar",
Column( bio = "这是一个示例用户的签名,用于展示用户详情卡片的样式。",
modifier = Modifier.padding(top = 16.dp) email = "123@example.com",
) { isoRegionName = "示例地区",
// 标题 phoneNumber = "+1234567890",
Row( password = "password",
modifier = Modifier )
.fillMaxWidth() UserProfileSection(
.padding(horizontal = 16.dp, vertical = 8.dp), user = sampleUser,
verticalAlignment = Alignment.CenterVertically onFollowClick = {},
) { sharedTransitionScope = null,
Text( animatedContentScope = null
text = "发布的帖子", )
style = MaterialTheme.typography.titleMedium,
fontFamily = TangyuanGeneralFontFamily,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "${posts.size}",
style = MaterialTheme.typography.bodySmall,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
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.ViewModel
import androidx.lifecycle.viewModelScope 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.PostMetadata
import com.qingshuige.tangyuan.model.User import com.qingshuige.tangyuan.model.User
import com.qingshuige.tangyuan.model.PostCard
import com.qingshuige.tangyuan.repository.UserRepository import com.qingshuige.tangyuan.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -11,6 +14,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.async
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -22,9 +26,9 @@ class UserDetailViewModel @Inject constructor(
private val _user = MutableStateFlow<User?>(null) private val _user = MutableStateFlow<User?>(null)
val user: StateFlow<User?> = _user.asStateFlow() val user: StateFlow<User?> = _user.asStateFlow()
// 用户帖子列表状态 // 用户帖子列表状态 - 改为PostCard列表
private val _userPosts = MutableStateFlow<List<PostMetadata>>(emptyList()) private val _userPosts = MutableStateFlow<List<PostCard>>(emptyList())
val userPosts: StateFlow<List<PostMetadata>> = _userPosts.asStateFlow() val userPosts: StateFlow<List<PostCard>> = _userPosts.asStateFlow()
// 加载状态 // 加载状态
private val _isLoading = MutableStateFlow(false) private val _isLoading = MutableStateFlow(false)
@ -55,15 +59,15 @@ class UserDetailViewModel @Inject constructor(
_user.value = userInfo _user.value = userInfo
_isLoading.value = false _isLoading.value = false
// 获取用户信息成功后,加载用户的帖子 // 获取用户信息成功后,加载用户的帖子
loadUserPosts(userId) loadUserPosts(userId, userInfo)
} }
} }
} }
/** /**
* 加载用户的帖子列表 * 加载用户的帖子列表包含完整的PostCard信息
*/ */
private fun loadUserPosts(userId: Int) { private fun loadUserPosts(userId: Int, user: User) {
viewModelScope.launch { viewModelScope.launch {
_isPostsLoading.value = true _isPostsLoading.value = true
@ -73,7 +77,20 @@ class UserDetailViewModel @Inject constructor(
_isPostsLoading.value = false _isPostsLoading.value = false
} }
.collect { posts -> .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 _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
)
}