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:
parent
39b5c3e40f
commit
0a0491ca1b
2
.idea/deploymentTargetSelector.xml
generated
2
.idea/deploymentTargetSelector.xml
generated
@ -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" />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -156,7 +156,8 @@ private fun PostList(
|
||||
onMoreClick = onMoreClick,
|
||||
onImageClick = onImageClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope
|
||||
animatedContentScope = animatedContentScope,
|
||||
sharedElementPrefix = "talk_post_${postCard.postId}" // 使用帖子ID作为唯一前缀
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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,60 +72,103 @@ fun UserDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
viewModel.refreshUserData(userId)
|
||||
isRefreshing = false
|
||||
Scaffold(
|
||||
topBar = {
|
||||
UserDetailTopBar(
|
||||
onBackClick = onBackClick,
|
||||
userName = user?.nickName ?: "",
|
||||
isLoading = isLoading
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 100.dp)
|
||||
) { paddingValues ->
|
||||
PullToRefreshBox(
|
||||
isRefreshing = isRefreshing,
|
||||
onRefresh = {
|
||||
isRefreshing = true
|
||||
viewModel.refreshUserData(userId)
|
||||
isRefreshing = false
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
// 顶部导航栏
|
||||
item {
|
||||
UserDetailTopBar(
|
||||
onBackClick = onBackClick,
|
||||
userName = user?.nickName ?: ""
|
||||
)
|
||||
}
|
||||
|
||||
// 用户信息卡片
|
||||
item {
|
||||
if (isLoading) {
|
||||
UserDetailLoadingCard()
|
||||
} else {
|
||||
user?.let { userInfo ->
|
||||
UserDetailCard(
|
||||
user = userInfo,
|
||||
onFollowClick = onFollowClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedContentScope = animatedContentScope
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 100.dp)
|
||||
) {
|
||||
// 用户信息区域
|
||||
item {
|
||||
if (isLoading) {
|
||||
UserProfileLoadingState()
|
||||
} else {
|
||||
user?.let { userInfo ->
|
||||
UserProfileSection(
|
||||
user = userInfo,
|
||||
onFollowClick = onFollowClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
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)
|
||||
}
|
||||
|
||||
// 用户帖子列表
|
||||
item {
|
||||
PostsSection(
|
||||
posts = userPosts,
|
||||
isLoading = isPostsLoading,
|
||||
onPostClick = onPostClick
|
||||
)
|
||||
if (isPostsLoading && userPosts.isEmpty()) {
|
||||
item {
|
||||
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 = {
|
||||
Text(
|
||||
text = userName.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (isLoading) {
|
||||
Text(
|
||||
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,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)
|
||||
@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))
|
||||
|
||||
// 用户名
|
||||
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()) {
|
||||
// 右侧昵称和信息
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
// 昵称 - 支持共享元素动画
|
||||
Text(
|
||||
text = user.bio.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = LiteraryFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 22.sp
|
||||
text = user.nickName.withPanguSpacing(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
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()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
// 地区和邮箱信息 - 右侧划入动画
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = slideInHorizontally(
|
||||
initialOffsetX = { it },
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessMedium
|
||||
)
|
||||
) + fadeIn(
|
||||
animationSpec = tween(400, delayMillis = 100)
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.LocationOn,
|
||||
contentDescription = "地区",
|
||||
tint = MaterialTheme.colorScheme.tertiary,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = user.isoRegionName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = TangyuanGeneralFontFamily,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
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(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))
|
||||
|
||||
// 关注按钮
|
||||
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
|
||||
private fun UserStatsSection(
|
||||
postsCount: Int,
|
||||
user: User
|
||||
postsCount: Int
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@ -346,7 +496,7 @@ private fun UserStatsSection(
|
||||
|
||||
StatItem(
|
||||
label = "关注",
|
||||
value = "0" // TODO: 从API获取关注数
|
||||
value = "0" // TODO: 从 API 获取关注数
|
||||
)
|
||||
|
||||
VerticalDivider(
|
||||
@ -356,12 +506,134 @@ private fun UserStatsSection(
|
||||
|
||||
StatItem(
|
||||
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
|
||||
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
|
||||
)
|
||||
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)
|
||||
} ?: "未知时间"
|
||||
fun UserProfilePreview() {
|
||||
val sampleUser = User(
|
||||
userId = 1,
|
||||
nickName = "示例用户",
|
||||
avatarGuid = "sample_avatar",
|
||||
bio = "这是一个示例用户的签名,用于展示用户详情卡片的样式。",
|
||||
email = "123@example.com",
|
||||
isoRegionName = "示例地区",
|
||||
phoneNumber = "+1234567890",
|
||||
password = "password",
|
||||
)
|
||||
UserProfileSection(
|
||||
user = sampleUser,
|
||||
onFollowClick = {},
|
||||
sharedTransitionScope = null,
|
||||
animatedContentScope = null
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user