feat: Implement user detail screen and refactor navigation

This commit introduces a dedicated user detail screen, enabling users to view profiles, and refactors the navigation flow, particularly for post and image details, to be more modular and robust.

**Key Changes:**

*   **feat(UserDetailScreen):**
    *   Added a new `UserDetailScreen` to display user information, including their avatar, name, bio, and a list of their posts.
    *   Implemented a `UserDetailViewModel` to fetch user data and their associated posts from the repository.
    *   The screen is composed of several modular components: `UserDetailCard`, `UserStatsSection`, and `PostsSection`.
    *   Includes loading skeletons and an empty state for the user's post list.
    *   Integrated a pull-to-refresh mechanism to update user data.

*   **feat(Navigation):**
    *   Added a new `UserDetail` route (`user_detail/{userId}`) to the navigation graph.
    *   Users can now navigate from a post card or post detail view to the author's `UserDetailScreen`.
    *   Clicking a post in the `UserDetailScreen` navigates to the corresponding `PostDetailScreen`.

*   **refactor(Navigation & Post/Image Detail):**
    *   Decoupled the text and image detail views, removing the `PostDetailContainer` that previously managed mode switching with `AnimatedContent`.
    *   `PostDetailScreen` and `ImageDetailScreen` are now independent navigation destinations.
    *   Navigation from a post to its image view is now a direct navigation action to `ImageDetailScreen`, simplifying the logic.
    *   The route for `PostDetail` has been simplified to `post_detail/{postId}`, removing the `mode` parameter.

*   **feat(Shared Element Transition):**
    *   Implemented a shared element transition for the user avatar, animating it from `PostCardItem` to `UserDetailScreen`.
    *   The avatar transition key is based on `user_avatar_{userId}` for consistent animations.

*   **refactor(ImageDetailScreen):**
    *   Improved the shared element transition for images by setting `placeHolderSize` to `animatedSize` and disabling `renderInOverlayDuringTransition`, which can resolve certain animation artifacts.

*   **refactor(Repository):**
    *   Added `getUserPosts(userId)` to the `UserRepository` to fetch all post metadata for a specific user.
This commit is contained in:
grtsinry43 2025-10-06 11:40:55 +08:00
parent e373670715
commit 39b5c3e40f
Signed by: grtsinry43
GPG Key ID: F3305FB3A978C934
7 changed files with 858 additions and 101 deletions

View File

@ -23,6 +23,7 @@ import com.qingshuige.tangyuan.ui.screens.PostDetailScreen
import com.qingshuige.tangyuan.ui.screens.ImageDetailScreen
import com.qingshuige.tangyuan.ui.screens.TalkScreen
import com.qingshuige.tangyuan.ui.screens.LoginScreen
import com.qingshuige.tangyuan.ui.screens.UserDetailScreen
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
@ -41,7 +42,7 @@ fun App() {
navController.navigate(Screen.PostDetail.createRoute(postId))
},
onImageClick = { postId, imageIndex ->
navController.navigate(Screen.PostDetail.createRoute(postId, "image") + "?imageIndex=$imageIndex")
navController.navigate(Screen.ImageDetail.createRoute(postId, imageIndex))
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable
@ -81,115 +82,85 @@ fun App() {
LoginScreen(navController = navController)
}
// 帖子详情页 - 统一容器管理两种模式
// 帖子详情页
composable(
route = Screen.PostDetail.route + "?imageIndex={imageIndex}",
route = Screen.PostDetail.route,
arguments = listOf(
navArgument("postId") { type = NavType.IntType },
navArgument("mode") { type = NavType.StringType; defaultValue = "text" },
navArgument("imageIndex") {
type = NavType.IntType
defaultValue = 0
nullable = false
}
navArgument("postId") { type = NavType.IntType }
)
) { backStackEntry ->
val postId = backStackEntry.arguments?.getInt("postId") ?: 0
val initialMode = backStackEntry.arguments?.getString("mode") ?: "text"
val imageIndex = backStackEntry.arguments?.getInt("imageIndex") ?: 0
PostDetailContainer(
PostDetailScreen(
postId = postId,
initialMode = initialMode,
initialImageIndex = imageIndex,
onBackClick = { navController.popBackStack() },
onAuthorClick = { authorId ->
// TODO: 导航到用户详情页
navController.navigate(Screen.UserDetail.createRoute(authorId))
},
onImageClick = { postId, imageIndex ->
navController.navigate(Screen.ImageDetail.createRoute(postId, imageIndex)) {
popUpTo(Screen.PostDetail.createRoute(postId)) {
inclusive = true
}
launchSingleTop = true
}
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable
)
}
}
}
}
/**
* 帖子详情容器 - 统一管理文字和图片两种模式
* 保持与PostCard的共享元素动画同时支持内部模式切换动画
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun PostDetailContainer(
postId: Int,
initialMode: String,
initialImageIndex: Int = 0,
onBackClick: () -> Unit,
onAuthorClick: (Int) -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedContentScope: AnimatedContentScope
) {
// 本地状态管理模式切换
var currentMode by remember { mutableStateOf(initialMode) }
var imageIndex by remember { mutableIntStateOf(initialImageIndex) }
// 图片详情页
composable(
route = Screen.ImageDetail.route,
arguments = listOf(
navArgument("postId") { type = NavType.IntType },
navArgument("imageIndex") { type = NavType.IntType }
)
) { backStackEntry ->
val postId = backStackEntry.arguments?.getInt("postId") ?: 0
val imageIndex = backStackEntry.arguments?.getInt("imageIndex") ?: 0
// 为模式切换创建内部AnimatedContent
AnimatedContent(
targetState = currentMode,
transitionSpec = {
// 使用更流畅的动画让切换更自然
when {
targetState == "image" && initialState == "text" -> {
slideInVertically(
initialOffsetY = { it },
animationSpec = tween(500, easing = FastOutSlowInEasing)
) togetherWith slideOutVertically(
targetOffsetY = { -it },
animationSpec = tween(500, easing = FastOutSlowInEasing)
)
}
targetState == "text" && initialState == "image" -> {
slideInVertically(
initialOffsetY = { -it },
animationSpec = tween(500, easing = FastOutSlowInEasing)
) togetherWith slideOutVertically(
targetOffsetY = { it },
animationSpec = tween(500, easing = FastOutSlowInEasing)
)
}
else -> {
fadeIn(animationSpec = tween(400)) togetherWith
fadeOut(animationSpec = tween(400))
}
}
},
label = "detail_mode_switch"
) { mode ->
when (mode) {
"image" -> {
ImageDetailScreen(
postId = postId,
initialImageIndex = imageIndex,
onBackClick = onBackClick,
onAuthorClick = onAuthorClick,
onSwitchToTextMode = {
currentMode = "text"
onBackClick = { navController.popBackStack() },
onAuthorClick = { authorId ->
navController.navigate(Screen.UserDetail.createRoute(authorId))
},
sharedTransitionScope = if (mode == initialMode) sharedTransitionScope else null,
animatedContentScope = if (mode == initialMode) animatedContentScope else null
onSwitchToTextMode = {
navController.navigate(Screen.PostDetail.createRoute(postId)) {
popUpTo(Screen.ImageDetail.createRoute(postId, imageIndex)) {
inclusive = true
}
launchSingleTop = true
}
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable
)
}
else -> {
PostDetailScreen(
postId = postId,
onBackClick = onBackClick,
onAuthorClick = onAuthorClick,
onImageClick = { _, selectedImageIndex ->
imageIndex = selectedImageIndex
currentMode = "image"
// 用户详情页
composable(
route = Screen.UserDetail.route,
arguments = listOf(
navArgument("userId") { type = NavType.IntType }
)
) { backStackEntry ->
val userId = backStackEntry.arguments?.getInt("userId") ?: 0
UserDetailScreen(
userId = userId,
onBackClick = { navController.popBackStack() },
onPostClick = { postId ->
navController.navigate(Screen.PostDetail.createRoute(postId))
},
sharedTransitionScope = if (mode == initialMode) sharedTransitionScope else null,
animatedContentScope = if (mode == initialMode) animatedContentScope else null
onFollowClick = {
// TODO: 实现关注功能
},
sharedTransitionScope = this@SharedTransitionLayout,
animatedContentScope = this@composable
)
}
}
@ -210,7 +181,8 @@ fun MainFlow(
val currentDestination = navBackStackEntry?.destination
val bottomBarScreens = listOf(Screen.Talk, Screen.Topic, Screen.Message, Screen.User)
val currentScreen = bottomBarScreens.find { it.route == currentDestination?.route } ?: Screen.Talk
val currentScreen =
bottomBarScreens.find { it.route == currentDestination?.route } ?: Screen.Talk
Scaffold(
modifier = Modifier.fillMaxSize(),
@ -220,8 +192,8 @@ fun MainFlow(
avatarUrl = "https://dogeoss.grtsinry43.com/img/author.jpeg",
pageLevel = PageLevel.PRIMARY,
onAvatarClick = onLoginClick,
onAnnouncementClick = { /* 公告点击事件 */ },
onPostClick = { /* 发表点击事件 */ }
onAnnouncementClick = {/* 公告点击事件 */ },
onPostClick = {/* 发表点击事件 */ }
)
},
bottomBar = {

View File

@ -6,10 +6,13 @@ sealed class Screen(val route: String, val title: String) {
object Topic : Screen("topic", "侃一侃")
object Message : Screen("message", "消息")
object User : Screen("settings", "我的")
object PostDetail : Screen("post_detail/{postId}/{mode}", "帖子详情") {
fun createRoute(postId: Int, mode: String = "text") = "post_detail/$postId/$mode"
object PostDetail : Screen("post_detail/{postId}", "帖子详情") {
fun createRoute(postId: Int) = "post_detail/$postId"
}
object ImageDetail : Screen("image_detail/{postId}/{imageIndex}", "图片详情") {
fun createRoute(postId: Int, imageIndex: Int) = "image_detail/$postId/$imageIndex"
}
object UserDetail : Screen("user_detail/{userId}", "用户详情") {
fun createRoute(userId: Int) = "user_detail/$userId"
}
}

View File

@ -62,4 +62,14 @@ class UserRepository @Inject constructor(
throw Exception("Search failed: ${response.message()}")
}
}
fun getUserPosts(userId: Int): Flow<List<com.qingshuige.tangyuan.model.PostMetadata>> = flow {
val response = apiInterface.getMetadatasByUserID(userId).awaitResponse()
if (response.isSuccessful) {
response.body()?.let { emit(it) }
?: emit(emptyList())
} else {
throw Exception("Failed to get user posts: ${response.message()}")
}
}
}

View File

@ -174,7 +174,9 @@ fun PostCardItem(
PostCardHeader(
postCard = postCard,
onAuthorClick = onAuthorClick,
onMoreClick = onMoreClick
onMoreClick = onMoreClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope
)
Spacer(modifier = Modifier.height(12.dp))
@ -216,11 +218,14 @@ fun PostCardItem(
/**
* 文章卡片头部 - 作者信息
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun PostCardHeader(
postCard: PostCard,
onAuthorClick: (Int) -> Unit,
onMoreClick: (Int) -> Unit
onMoreClick: (Int) -> Unit,
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null
) {
Row(
modifier = Modifier.fillMaxWidth(),
@ -232,7 +237,20 @@ private fun PostCardHeader(
contentDescription = "${postCard.authorName}的头像",
modifier = Modifier
.size(40.dp)
.clip(CircleShape),
.clip(CircleShape)
.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
},
contentScale = ContentScale.Crop,
onClick = { onAuthorClick(postCard.authorId) }
)

View File

@ -283,7 +283,9 @@ private fun ZoomableImage(
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
},
placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize,
renderInOverlayDuringTransition = false
)
}
} else mod

View File

@ -0,0 +1,635 @@
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
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
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.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.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.*
/**
* 用户详情页
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class)
@Composable
fun UserDetailScreen(
userId: Int,
onBackClick: () -> Unit,
onPostClick: (Int) -> Unit = {},
onFollowClick: () -> Unit = {},
sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null,
viewModel: UserDetailViewModel = hiltViewModel()
) {
val user by viewModel.user.collectAsState()
val userPosts by viewModel.userPosts.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val isPostsLoading by viewModel.isPostsLoading.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
var isRefreshing by remember { mutableStateOf(false) }
// 初始加载
LaunchedEffect(userId) {
viewModel.loadUserDetails(userId)
}
// 错误提示
errorMessage?.let { message ->
LaunchedEffect(message) {
// TODO: 显示错误提示
viewModel.clearError()
}
}
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
viewModel.refreshUserData(userId)
isRefreshing = false
},
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 100.dp)
) {
// 顶部导航栏
item {
UserDetailTopBar(
onBackClick = onBackClick,
userName = user?.nickName ?: ""
)
}
// 用户信息卡片
item {
if (isLoading) {
UserDetailLoadingCard()
} else {
user?.let { userInfo ->
UserDetailCard(
user = userInfo,
onFollowClick = onFollowClick,
sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope
)
}
}
}
// 统计信息
item {
user?.let { userInfo ->
UserStatsSection(
postsCount = userPosts.size,
user = userInfo
)
}
}
// 用户帖子列表
item {
PostsSection(
posts = userPosts,
isLoading = isPostsLoading,
onPostClick = onPostClick
)
}
}
}
}
/**
* 顶部导航栏
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun UserDetailTopBar(
onBackClick: () -> Unit,
userName: String
) {
TopAppBar(
title = {
Text(
text = userName.withPanguSpacing(),
style = MaterialTheme.typography.titleLarge,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "返回",
tint = MaterialTheme.colorScheme.onSurface
)
}
},
actions = {
IconButton(onClick = { /* TODO: 更多操作 */ }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "更多",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)
)
)
}
/**
* 用户详情卡片
*/
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun UserDetailCard(
user: User,
onFollowClick: () -> Unit,
sharedTransitionScope: SharedTransitionScope?,
animatedContentScope: AnimatedContentScope?
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = TangyuanShapes.CulturalCard,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(
defaultElevation = 4.dp
)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 用户头像 - 支持共享元素动画
ShimmerAsyncImage(
imageUrl = "${TangyuanApplication.instance.bizDomain}images/${user.avatarGuid}.jpg",
contentDescription = "${user.nickName}的头像",
modifier = Modifier
.size(120.dp)
.clip(CircleShape)
.let { mod ->
if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) {
mod.sharedElement(
rememberSharedContentState(key = "user_avatar_${user.userId}"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing)
}
)
}
} else mod
},
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(16.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()) {
Text(
text = user.bio.withPanguSpacing(),
style = MaterialTheme.typography.bodyMedium,
fontFamily = LiteraryFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(16.dp))
}
// 地区信息
if (user.isoRegionName.isNotBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 4.dp)
) {
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
)
}
}
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
)
}
}
}
}
/**
* 统计信息区域
*/
@Composable
private fun UserStatsSection(
postsCount: Int,
user: User
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = MaterialTheme.shapes.medium,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
StatItem(
label = "帖子",
value = postsCount.toString()
)
VerticalDivider(
modifier = Modifier.height(40.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
StatItem(
label = "关注",
value = "0" // TODO: 从API获取关注数
)
VerticalDivider(
modifier = Modifier.height(40.dp),
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
StatItem(
label = "粉丝",
value = "0" // TODO: 从API获取粉丝数
)
}
}
}
/**
* 统计项组件
*/
@Composable
private fun StatItem(
label: String,
value: String
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = value,
style = TangyuanTypography.numberMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* 帖子列表区域
*/
@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)
} ?: "未知时间"
}

View File

@ -0,0 +1,117 @@
package com.qingshuige.tangyuan.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.qingshuige.tangyuan.model.PostMetadata
import com.qingshuige.tangyuan.model.User
import com.qingshuige.tangyuan.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class UserDetailViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
// 用户信息状态
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()
// 加载状态
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
// 错误状态
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
// 帖子加载状态
private val _isPostsLoading = MutableStateFlow(false)
val isPostsLoading: StateFlow<Boolean> = _isPostsLoading.asStateFlow()
/**
* 加载用户详细信息
*/
fun loadUserDetails(userId: Int) {
viewModelScope.launch {
_isLoading.value = true
_errorMessage.value = null
userRepository.getUserById(userId)
.catch { e ->
_errorMessage.value = e.message ?: "获取用户信息失败"
_isLoading.value = false
}
.collect { userInfo ->
_user.value = userInfo
_isLoading.value = false
// 获取用户信息成功后,加载用户的帖子
loadUserPosts(userId)
}
}
}
/**
* 加载用户的帖子列表
*/
private fun loadUserPosts(userId: Int) {
viewModelScope.launch {
_isPostsLoading.value = true
userRepository.getUserPosts(userId)
.catch { e ->
// 帖子加载失败不影响用户信息显示
_isPostsLoading.value = false
}
.collect { posts ->
_userPosts.value = posts
_isPostsLoading.value = false
}
}
}
/**
* 刷新用户数据
*/
fun refreshUserData(userId: Int) {
loadUserDetails(userId)
}
/**
* 清除错误消息
*/
fun clearError() {
_errorMessage.value = null
}
/**
* 更新用户信息
*/
fun updateUserInfo(userId: Int, updatedUser: User) {
viewModelScope.launch {
_isLoading.value = true
_errorMessage.value = null
userRepository.updateUser(userId, updatedUser)
.catch { e ->
_errorMessage.value = e.message ?: "更新用户信息失败"
_isLoading.value = false
}
.collect { success ->
if (success) {
_user.value = updatedUser
}
_isLoading.value = false
}
}
}
}