From 93f95bf9c31a3f86c33bb9468fbfc2e358580443 Mon Sep 17 00:00:00 2001 From: grtsinry43 Date: Mon, 6 Oct 2025 01:29:15 +0800 Subject: [PATCH] feat: Implement image detail view and text/image mode switching This commit introduces a dedicated image detail screen and integrates it with the post detail view, allowing users to switch between a text-focused layout and an immersive, image-centric one. **Key Changes:** * **feat(ImageDetailScreen):** * Added a new `ImageDetailScreen` for a full-screen, immersive image viewing experience. * Implemented a `HorizontalPager` to allow swiping between multiple images. * Included zoom (pinch-to-zoom) and double-tap-to-zoom functionality for images. * Overlaid post information (author, content snippet) on a blurred background, which can be swiped up to switch back to the text detail view. * The background is a blurred version of the current image, creating an ambient effect. * **feat(Navigation):** * Created a `PostDetailContainer` to manage the animated transition between `PostDetailScreen` (text mode) and `ImageDetailScreen` (image mode). * Updated the navigation route for `PostDetail` to accept a `mode` parameter (`text` or `image`) to handle deep linking directly into the image view. * Clicking an image in the feed or post details now navigates to the image mode, preserving the shared element transition. * **refactor(PostDetail):** * Modified `PostCardItem` and `PostDetailScreen` to pass both `postId` and the image `index` on image clicks. * Refactored the image click handler to trigger the navigation to the new image detail mode. --- .../main/java/com/qingshuige/tangyuan/App.kt | 111 +++- .../qingshuige/tangyuan/navigation/Screen.kt | 7 +- .../tangyuan/ui/components/PostCardItem.kt | 17 +- .../tangyuan/ui/screens/ImageDetailScreen.kt | 562 ++++++++++++++++++ .../tangyuan/ui/screens/PostDetailScreen.kt | 18 +- .../tangyuan/ui/screens/TalkScreen.kt | 4 + 6 files changed, 694 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/qingshuige/tangyuan/ui/screens/ImageDetailScreen.kt diff --git a/app/src/main/java/com/qingshuige/tangyuan/App.kt b/app/src/main/java/com/qingshuige/tangyuan/App.kt index 2a8ee56..4ecdde4 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/App.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/App.kt @@ -6,8 +6,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavType @@ -21,6 +20,7 @@ import com.qingshuige.tangyuan.ui.components.PageLevel import com.qingshuige.tangyuan.ui.components.TangyuanBottomAppBar import com.qingshuige.tangyuan.ui.components.TangyuanTopBar 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 @@ -40,6 +40,9 @@ fun App() { onPostClick = { postId -> navController.navigate(Screen.PostDetail.createRoute(postId)) }, + onImageClick = { postId, imageIndex -> + navController.navigate(Screen.PostDetail.createRoute(postId, "image") + "?imageIndex=$imageIndex") + }, sharedTransitionScope = this@SharedTransitionLayout, animatedContentScope = this@composable ) @@ -78,21 +81,24 @@ fun App() { LoginScreen(navController = navController) } - // 帖子详情页 - 只有共享元素动画,无页面切换动画 + // 帖子详情页 - 统一容器管理两种模式 composable( route = Screen.PostDetail.route, - arguments = listOf(navArgument("postId") { type = NavType.IntType }) + arguments = listOf( + navArgument("postId") { type = NavType.IntType }, + navArgument("mode") { type = NavType.StringType; defaultValue = "text" } + ) ) { backStackEntry -> val postId = backStackEntry.arguments?.getInt("postId") ?: 0 - PostDetailScreen( + val initialMode = backStackEntry.arguments?.getString("mode") ?: "text" + + PostDetailContainer( postId = postId, + initialMode = initialMode, onBackClick = { navController.popBackStack() }, onAuthorClick = { authorId -> // TODO: 导航到用户详情页 }, - onImageClick = { imageUuid -> - // TODO: 导航到图片查看页 - }, sharedTransitionScope = this@SharedTransitionLayout, animatedContentScope = this@composable ) @@ -101,11 +107,99 @@ fun App() { } } +/** + * 帖子详情容器 - 统一管理文字和图片两种模式 + * 保持与PostCard的共享元素动画,同时支持内部模式切换动画 + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun PostDetailContainer( + postId: Int, + initialMode: String, + onBackClick: () -> Unit, + onAuthorClick: (Int) -> Unit, + sharedTransitionScope: SharedTransitionScope, + animatedContentScope: AnimatedContentScope +) { + // 本地状态管理模式切换 + var currentMode by remember { mutableStateOf(initialMode) } + var imageIndex by remember { mutableIntStateOf(0) } + + // 为模式切换创建内部AnimatedContent + AnimatedContent( + targetState = currentMode, + transitionSpec = { + // 使用滑动动画让切换更自然 + when { + targetState == "image" && initialState == "text" -> { + slideInVertically( + initialOffsetY = { it }, + animationSpec = tween(400) + ) togetherWith slideOutVertically( + targetOffsetY = { -it }, + animationSpec = tween(400) + ) + } + targetState == "text" && initialState == "image" -> { + slideInVertically( + initialOffsetY = { -it }, + animationSpec = tween(400) + ) togetherWith slideOutVertically( + targetOffsetY = { it }, + animationSpec = tween(400) + ) + } + else -> { + fadeIn(animationSpec = tween(300)) togetherWith + fadeOut(animationSpec = tween(300)) + } + } + }, + label = "detail_mode_switch" + ) { mode -> + when (mode) { + "image" -> { + ImageDetailScreen( + postId = postId, + initialImageIndex = imageIndex, + onBackClick = onBackClick, + onAuthorClick = onAuthorClick, + onSwitchToTextMode = { + currentMode = "text" + }, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = this@AnimatedContent + ) + } + else -> { + PostDetailScreen( + postId = postId, + onBackClick = onBackClick, + onAuthorClick = onAuthorClick, + onImageClick = { _, selectedImageIndex -> + imageIndex = selectedImageIndex + currentMode = "image" + }, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = if (mode == initialMode) { + // 如果是初始模式,使用外部的animatedContentScope来保持与PostCard的共享动画 + animatedContentScope + } else { + // 如果是切换后的模式,使用内部的AnimatedContent scope + this@AnimatedContent + } + ) + } + } + } +} + @OptIn(ExperimentalSharedTransitionApi::class) @Composable fun MainFlow( onLoginClick: () -> Unit, onPostClick: (Int) -> Unit, + onImageClick: (Int, Int) -> Unit = { _, _ -> }, sharedTransitionScope: SharedTransitionScope? = null, animatedContentScope: AnimatedContentScope? = null ) { @@ -151,6 +245,7 @@ fun MainFlow( onAuthorClick = { authorId -> // TODO: 导航到用户详情页 }, + onImageClick = onImageClick, sharedTransitionScope = sharedTransitionScope, animatedContentScope = animatedContentScope ) 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 e22083a..57a9b43 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/navigation/Screen.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/navigation/Screen.kt @@ -6,7 +6,10 @@ 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}", "帖子详情") { - fun createRoute(postId: Int) = "post_detail/$postId" + object PostDetail : Screen("post_detail/{postId}/{mode}", "帖子详情") { + fun createRoute(postId: Int, mode: String = "text") = "post_detail/$postId/$mode" + } + object ImageDetail : Screen("image_detail/{postId}/{imageIndex}", "图片详情") { + fun createRoute(postId: Int, imageIndex: Int) = "image_detail/$postId/$imageIndex" } } \ 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 294a5ce..61945b6 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 @@ -132,6 +132,7 @@ fun PostCardItem( onShareClick: (Int) -> Unit = {}, onBookmarkClick: (Int) -> Unit = {}, onMoreClick: (Int) -> Unit = {}, + onImageClick: (Int, Int) -> Unit = { _, _ -> }, modifier: Modifier = Modifier, sharedTransitionScope: SharedTransitionScope? = null, animatedContentScope: AnimatedContentScope? = null @@ -185,7 +186,8 @@ fun PostCardItem( Spacer(modifier = Modifier.height(12.dp)) PostCardImages( imageUUIDs = postCard.imageUUIDs, - onImageClick = { /* TODO: 查看大图 */ } + postId = postCard.postId, + onImageClick = onImageClick ) } @@ -299,7 +301,8 @@ private fun PostCardContent(postCard: PostCard) { @Composable private fun PostCardImages( imageUUIDs: List, - onImageClick: (String) -> Unit + postId: Int, + onImageClick: (Int, Int) -> Unit ) { when (imageUUIDs.size) { 1 -> { @@ -312,7 +315,7 @@ private fun PostCardImages( .height(200.dp) .clip(MaterialTheme.shapes.medium), contentScale = ContentScale.Crop, - onClick = { onImageClick(imageUUIDs[0]) } + onClick = { onImageClick(postId, 0) } ) } @@ -321,7 +324,7 @@ private fun PostCardImages( Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - imageUUIDs.forEach { uuid -> + imageUUIDs.forEachIndexed { index, uuid -> ShimmerAsyncImage( imageUrl = "${TangyuanApplication.instance.bizDomain}images/$uuid.jpg", contentDescription = "文章图片", @@ -330,7 +333,7 @@ private fun PostCardImages( .height(120.dp) .clip(MaterialTheme.shapes.medium), contentScale = ContentScale.Crop, - onClick = { onImageClick(uuid) } + onClick = { onImageClick(postId, index) } ) } } @@ -341,7 +344,7 @@ private fun PostCardImages( Row( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - imageUUIDs.forEach { uuid -> + imageUUIDs.forEachIndexed { index, uuid -> ShimmerAsyncImage( imageUrl = "${TangyuanApplication.instance.bizDomain}images/$uuid.jpg", contentDescription = "文章图片", @@ -350,7 +353,7 @@ private fun PostCardImages( .height(100.dp) .clip(MaterialTheme.shapes.medium), contentScale = ContentScale.Crop, - onClick = { onImageClick(uuid) } + onClick = { onImageClick(postId, index) } ) } } 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 new file mode 100644 index 0000000..65724a0 --- /dev/null +++ b/app/src/main/java/com/qingshuige/tangyuan/ui/screens/ImageDetailScreen.kt @@ -0,0 +1,562 @@ +package com.qingshuige.tangyuan.ui.screens + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +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.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +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.PostCard +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.utils.withPanguSpacing +import com.qingshuige.tangyuan.viewmodel.PostDetailViewModel +import kotlin.math.max +import kotlin.math.min + +/** + * 图片详情页面 - 以图片为主的展示界面 + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalSharedTransitionApi::class) +@Composable +fun ImageDetailScreen( + postId: Int, + initialImageIndex: Int = 0, + onBackClick: () -> Unit = {}, + onAuthorClick: (Int) -> Unit = {}, + onSwitchToTextMode: () -> Unit = {}, + viewModel: PostDetailViewModel = hiltViewModel(), + sharedTransitionScope: SharedTransitionScope? = null, + animatedContentScope: AnimatedContentScope? = null +) { + val state by viewModel.state.collectAsState() + + // 加载帖子详情 + LaunchedEffect(postId) { + viewModel.loadPostDetail(postId) + } + + // 当有帖子数据时才显示内容 + state.postCard?.let { postCard -> + val imageUUIDs = postCard.imageUUIDs + val validImageIndex = initialImageIndex.coerceIn(0, imageUUIDs.size - 1) + val pagerState = rememberPagerState( + initialPage = validImageIndex, + pageCount = { imageUUIDs.size } + ) + + Box(modifier = Modifier.fillMaxSize()) { + // 背景图片(模糊效果) + BackgroundBlurredImage( + imageUUIDs = imageUUIDs, + currentPage = pagerState.currentPage + ) + + // 主要内容 + Column(modifier = Modifier.fillMaxSize()) { + // 顶部导航栏 + ImageDetailTopBar( + onBackClick = onBackClick, + currentIndex = pagerState.currentPage + 1, + totalCount = imageUUIDs.size + ) + + // 图片轮播区域 + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + if (imageUUIDs.isNotEmpty()) { + ImagePager( + imageUUIDs = imageUUIDs, + postId = postCard.postId, + pagerState = pagerState, + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope + ) + } + } + + // 底部内容区域(模糊遮罩) + BottomContentOverlay( + postCard = postCard, + onAuthorClick = onAuthorClick, + onSwitchToTextMode = onSwitchToTextMode + ) + } + } + } + + // 加载状态 + if (state.isLoading && state.postCard == null) { + LoadingContent() + } + + // 错误状态 + state.error?.let { error -> + ErrorContent( + message = error, + onRetry = { + viewModel.clearError() + viewModel.loadPostDetail(postId) + } + ) + } +} + +/** + * 背景模糊图片 + */ +@Composable +private fun BackgroundBlurredImage( + imageUUIDs: List, + currentPage: Int +) { + if (imageUUIDs.isNotEmpty() && currentPage < imageUUIDs.size) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data("${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[currentPage]}.jpg") + .crossfade(true) + .build(), + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .blur(radius = 20.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded), + contentScale = ContentScale.Crop, + alpha = 0.3f + ) + } + + // 渐变遮罩 + Box( + modifier = Modifier + .fillMaxSize() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Black.copy(alpha = 0.4f), + Color.Black.copy(alpha = 0.1f), + Color.Black.copy(alpha = 0.6f) + ) + ) + ) + ) +} + +/** + * 顶部导航栏 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ImageDetailTopBar( + onBackClick: () -> Unit, + currentIndex: Int, + totalCount: Int +) { + TopAppBar( + title = { + Text( + text = "$currentIndex / $totalCount", + fontFamily = TangyuanGeneralFontFamily, + color = Color.White, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = "返回", + tint = Color.White + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent + ) + ) +} + +/** + * 图片轮播组件 + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun ImagePager( + imageUUIDs: List, + postId: Int, + pagerState: PagerState, + sharedTransitionScope: SharedTransitionScope? = null, + animatedContentScope: AnimatedContentScope? = null +) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + ZoomableImage( + imageUrl = "${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[page]}.jpg", + imageUuid = imageUUIDs[page], + postId = postId, + contentDescription = "图片 ${page + 1}", + sharedTransitionScope = sharedTransitionScope, + animatedContentScope = animatedContentScope + ) + } +} + +/** + * 可缩放的图片组件 + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun ZoomableImage( + imageUrl: String, + imageUuid: String, + postId: Int, + contentDescription: String, + sharedTransitionScope: SharedTransitionScope? = null, + animatedContentScope: AnimatedContentScope? = null +) { + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + val transformableState = rememberTransformableState { zoomChange, offsetChange, _ -> + scale = (scale * zoomChange).coerceIn(1f, 5f) + val maxX = (scale - 1) * 300 + val maxY = (scale - 1) * 300 + offset = Offset( + x = (offset.x + offsetChange.x).coerceIn(-maxX, maxX), + y = (offset.y + offsetChange.y).coerceIn(-maxY, maxY) + ) + } + + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentDescription = contentDescription, + modifier = Modifier + .fillMaxSize() + .let { mod -> + if (sharedTransitionScope != null && animatedContentScope != null) { + with(sharedTransitionScope) { + mod.sharedElement( + rememberSharedContentState(key = "post_card_$postId"), + animatedVisibilityScope = animatedContentScope, + boundsTransform = { _, _ -> + tween(durationMillis = 500) + } + ) + } + } else mod + } + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offset.x, + translationY = offset.y + ) + .transformable(state = transformableState) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + scale = if (scale > 1f) 1f else 2f + offset = Offset.Zero + } + ) + }, + contentScale = ContentScale.Fit + ) + } +} + +/** + * 底部内容遮罩 + */ +@Composable +private fun BottomContentOverlay( + postCard: PostCard, + onAuthorClick: (Int) -> Unit, + onSwitchToTextMode: () -> Unit +) { + var offsetY by remember { mutableFloatStateOf(0f) } + val swipeThreshold = -100f // 上滑超过100px触发切换 + + Box( + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.8f) + ) + ) + ) + .offset(y = offsetY.dp) + .pointerInput(Unit) { + detectDragGestures( + onDragEnd = { + if (offsetY < swipeThreshold) { + onSwitchToTextMode() + } else { + offsetY = 0f + } + } + ) { _, dragAmount -> + offsetY = (offsetY + dragAmount.y).coerceAtMost(0f) + } + } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp) + ) { + // 作者信息 + PostAuthorInfo( + postCard = postCard, + onAuthorClick = onAuthorClick + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 文章内容 + Text( + text = postCard.textContent.withPanguSpacing(), + style = MaterialTheme.typography.bodyMedium.copy( + lineHeight = 22.sp + ), + fontFamily = LiteraryFontFamily, + color = Color.White, + maxLines = 4, + overflow = TextOverflow.Ellipsis + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // 分类和时间 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = RoundedCornerShape(12.dp), + color = Color.White.copy(alpha = 0.2f) + ) { + Text( + text = postCard.categoryName, + style = MaterialTheme.typography.labelMedium, + fontFamily = LiteraryFontFamily, + color = Color.White, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + fontWeight = FontWeight.Medium + ) + } + + Text( + text = postCard.getTimeDisplayText(), + style = MaterialTheme.typography.bodySmall, + fontFamily = TangyuanGeneralFontFamily, + color = Color.White.copy(alpha = 0.8f) + ) + } + + Spacer(modifier = Modifier.height(20.dp)) + + // 上滑提示 - 放在最下面居中 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Text( + text = "上滑查看详情", + style = MaterialTheme.typography.labelSmall, + fontFamily = TangyuanGeneralFontFamily, + color = Color.White.copy(alpha = 0.4f) + ) + } + } + } +} + +/** + * 作者信息组件 + */ +@Composable +private fun PostAuthorInfo( + postCard: PostCard, + onAuthorClick: (Int) -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable { onAuthorClick(postCard.authorId) } + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data("${TangyuanApplication.instance.bizDomain}images/${postCard.authorAvatar}.jpg") + .crossfade(true) + .build(), + contentDescription = "${postCard.authorName}的头像", + modifier = Modifier + .size(48.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column { + Text( + text = postCard.authorName.withPanguSpacing(), + style = MaterialTheme.typography.titleMedium, + fontFamily = TangyuanGeneralFontFamily, + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + + if (postCard.authorBio.isNotBlank()) { + Text( + text = postCard.authorBio.withPanguSpacing(), + style = MaterialTheme.typography.bodySmall, + fontFamily = LiteraryFontFamily, + color = Color.White.copy(alpha = 0.8f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +/** + * 加载状态组件 + */ +@Composable +private fun LoadingContent() { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator( + color = Color.White + ) + Text( + text = "正在加载图片...", + style = MaterialTheme.typography.bodyMedium, + fontFamily = LiteraryFontFamily, + color = Color.White + ) + } + } +} + +/** + * 错误状态组件 + */ +@Composable +private fun ErrorContent( + message: String, + onRetry: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier.padding(32.dp) + ) { + Icon( + imageVector = Icons.Default.ErrorOutline, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(48.dp) + ) + + Text( + text = "加载失败", + style = MaterialTheme.typography.headlineSmall, + fontFamily = LiteraryFontFamily, + color = Color.White, + fontWeight = FontWeight.SemiBold + ) + + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + fontFamily = LiteraryFontFamily, + color = Color.White.copy(alpha = 0.8f), + textAlign = TextAlign.Center + ) + + Button( + onClick = onRetry, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White.copy(alpha = 0.2f) + ) + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = Color.White + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "重试", + fontFamily = LiteraryFontFamily, + color = Color.White + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/qingshuige/tangyuan/ui/screens/PostDetailScreen.kt b/app/src/main/java/com/qingshuige/tangyuan/ui/screens/PostDetailScreen.kt index da8de41..e386ff8 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/ui/screens/PostDetailScreen.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/ui/screens/PostDetailScreen.kt @@ -51,7 +51,7 @@ fun PostDetailScreen( postId: Int, onBackClick: () -> Unit = {}, onAuthorClick: (Int) -> Unit = {}, - onImageClick: (String) -> Unit = {}, + onImageClick: (Int, Int) -> Unit = { _, _ -> }, viewModel: PostDetailViewModel = hiltViewModel(), sharedTransitionScope: SharedTransitionScope? = null, animatedContentScope: AnimatedContentScope? = null @@ -181,7 +181,7 @@ private fun PostDetailContent( isError: Boolean, errorMessage: String?, onAuthorClick: (Int) -> Unit, - onImageClick: (String) -> Unit, + onImageClick: (Int, Int) -> Unit, onReplyToComment: (CommentCard) -> Unit, onDeleteComment: (Int) -> Unit, onRetry: () -> Unit, @@ -256,7 +256,7 @@ private fun PostDetailContent( private fun PostDetailCard( postCard: PostCard, onAuthorClick: (Int) -> Unit, - onImageClick: (String) -> Unit, + onImageClick: (Int, Int) -> Unit, sharedTransitionScope: SharedTransitionScope? = null, animatedContentScope: AnimatedContentScope? = null ) { @@ -311,6 +311,7 @@ private fun PostDetailCard( Spacer(modifier = Modifier.height(16.dp)) PostDetailImages( imageUUIDs = postCard.imageUUIDs, + postId = postCard.postId, onImageClick = onImageClick ) } @@ -403,7 +404,8 @@ private fun PostDetailHeader( @Composable private fun PostDetailImages( imageUUIDs: List, - onImageClick: (String) -> Unit + postId: Int, + onImageClick: (Int, Int) -> Unit ) { when (imageUUIDs.size) { 1 -> { @@ -416,7 +418,7 @@ private fun PostDetailImages( modifier = Modifier .fillMaxWidth() .clip(MaterialTheme.shapes.medium) - .clickable { onImageClick(imageUUIDs[0]) }, + .clickable { onImageClick(postId, 0) }, contentScale = ContentScale.FillWidth ) } @@ -425,10 +427,10 @@ private fun PostDetailImages( horizontalArrangement = Arrangement.spacedBy(8.dp), contentPadding = PaddingValues(horizontal = 4.dp) ) { - items(imageUUIDs) { uuid -> + items(imageUUIDs.size) { index -> AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data("${TangyuanApplication.instance.bizDomain}images/$uuid.jpg") + .data("${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[index]}.jpg") .crossfade(true) .build(), contentDescription = "文章图片", @@ -436,7 +438,7 @@ private fun PostDetailImages( .width(200.dp) .height(150.dp) .clip(MaterialTheme.shapes.medium) - .clickable { onImageClick(uuid) }, + .clickable { onImageClick(postId, index) }, contentScale = ContentScale.Crop ) } diff --git a/app/src/main/java/com/qingshuige/tangyuan/ui/screens/TalkScreen.kt b/app/src/main/java/com/qingshuige/tangyuan/ui/screens/TalkScreen.kt index 644bee7..d92142f 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/ui/screens/TalkScreen.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/ui/screens/TalkScreen.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.launch fun TalkScreen( onPostClick: (Int) -> Unit = {}, onAuthorClick: (Int) -> Unit = {}, + onImageClick: (Int, Int) -> Unit = { _, _ -> }, viewModel: TalkViewModel = hiltViewModel(), sharedTransitionScope: SharedTransitionScope? = null, animatedContentScope: AnimatedContentScope? = null @@ -101,6 +102,7 @@ fun TalkScreen( onMoreClick = { postId -> // TODO: 显示更多操作菜单 }, + onImageClick = onImageClick, onErrorDismiss = viewModel::clearError, sharedTransitionScope = sharedTransitionScope, animatedContentScope = animatedContentScope @@ -129,6 +131,7 @@ private fun PostList( onShareClick: (Int) -> Unit, onBookmarkClick: (Int) -> Unit, onMoreClick: (Int) -> Unit, + onImageClick: (Int, Int) -> Unit, onErrorDismiss: () -> Unit, sharedTransitionScope: SharedTransitionScope? = null, animatedContentScope: AnimatedContentScope? = null @@ -151,6 +154,7 @@ private fun PostList( onShareClick = onShareClick, onBookmarkClick = onBookmarkClick, onMoreClick = onMoreClick, + onImageClick = onImageClick, sharedTransitionScope = sharedTransitionScope, animatedContentScope = animatedContentScope )