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:
parent
e373670715
commit
39b5c3e40f
@ -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 = {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
)
|
||||
|
||||
@ -283,7 +283,9 @@ private fun ZoomableImage(
|
||||
animatedVisibilityScope = animatedContentScope,
|
||||
boundsTransform = { _, _ ->
|
||||
tween(durationMillis = 400, easing = FastOutSlowInEasing)
|
||||
}
|
||||
},
|
||||
placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize,
|
||||
renderInOverlayDuringTransition = false
|
||||
)
|
||||
}
|
||||
} else mod
|
||||
|
||||
@ -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)
|
||||
} ?: "未知时间"
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user