diff --git a/app/src/main/java/com/qingshuige/tangyuan/App.kt b/app/src/main/java/com/qingshuige/tangyuan/App.kt index 145de27..1271612 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/App.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/App.kt @@ -23,12 +23,13 @@ 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 fun App() { val navController = rememberNavController() - + SharedTransitionLayout { NavHost( navController = navController, @@ -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 @@ -80,116 +81,86 @@ 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) } - - // 为模式切换创建内部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" -> { + // 图片详情页 + 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 + 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 = { @@ -241,7 +213,7 @@ fun MainFlow( startDestination = Screen.Talk.route, modifier = Modifier.padding(innerPadding) ) { - composable(Screen.Talk.route) { + composable(Screen.Talk.route) { TalkScreen( onPostClick = onPostClick, onAuthorClick = { authorId -> diff --git a/app/src/main/java/com/qingshuige/tangyuan/navigation/Screen.kt b/app/src/main/java/com/qingshuige/tangyuan/navigation/Screen.kt index 57a9b43..85ddfd3 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/navigation/Screen.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/navigation/Screen.kt @@ -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" + } } \ No newline at end of file diff --git a/app/src/main/java/com/qingshuige/tangyuan/repository/UserRepository.kt b/app/src/main/java/com/qingshuige/tangyuan/repository/UserRepository.kt index ea48cbc..83e72b5 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/repository/UserRepository.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/repository/UserRepository.kt @@ -62,4 +62,14 @@ class UserRepository @Inject constructor( throw Exception("Search failed: ${response.message()}") } } + + fun getUserPosts(userId: Int): Flow> = 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()}") + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/qingshuige/tangyuan/ui/components/PostCardItem.kt b/app/src/main/java/com/qingshuige/tangyuan/ui/components/PostCardItem.kt index 35b1d33..af81550 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/ui/components/PostCardItem.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/ui/components/PostCardItem.kt @@ -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) } ) diff --git a/app/src/main/java/com/qingshuige/tangyuan/ui/screens/ImageDetailScreen.kt b/app/src/main/java/com/qingshuige/tangyuan/ui/screens/ImageDetailScreen.kt index 152c27c..95a357a 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/ui/screens/ImageDetailScreen.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/ui/screens/ImageDetailScreen.kt @@ -283,7 +283,9 @@ private fun ZoomableImage( animatedVisibilityScope = animatedContentScope, boundsTransform = { _, _ -> tween(durationMillis = 400, easing = FastOutSlowInEasing) - } + }, + placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize, + renderInOverlayDuringTransition = false ) } } else mod diff --git a/app/src/main/java/com/qingshuige/tangyuan/ui/screens/UserDetailScreen.kt b/app/src/main/java/com/qingshuige/tangyuan/ui/screens/UserDetailScreen.kt new file mode 100644 index 0000000..61b89ac --- /dev/null +++ b/app/src/main/java/com/qingshuige/tangyuan/ui/screens/UserDetailScreen.kt @@ -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, + 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) + } ?: "未知时间" +} \ No newline at end of file diff --git a/app/src/main/java/com/qingshuige/tangyuan/viewmodel/UserDetailViewModel.kt b/app/src/main/java/com/qingshuige/tangyuan/viewmodel/UserDetailViewModel.kt new file mode 100644 index 0000000..4cd35b4 --- /dev/null +++ b/app/src/main/java/com/qingshuige/tangyuan/viewmodel/UserDetailViewModel.kt @@ -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(null) + val user: StateFlow = _user.asStateFlow() + + // 用户帖子列表状态 + private val _userPosts = MutableStateFlow>(emptyList()) + val userPosts: StateFlow> = _userPosts.asStateFlow() + + // 加载状态 + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + // 错误状态 + private val _errorMessage = MutableStateFlow(null) + val errorMessage: StateFlow = _errorMessage.asStateFlow() + + // 帖子加载状态 + private val _isPostsLoading = MutableStateFlow(false) + val isPostsLoading: StateFlow = _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 + } + } + } +} \ No newline at end of file