diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0a8c46d..b2a6576 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,11 @@ xmlns:tools="http://schemas.android.com/tools"> + + + 20) "..." else ""}() + else "说点什么...", fontFamily = LiteraryFontFamily, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -602,6 +606,25 @@ fun CommentInputBar( } } +@Preview +@Composable +private fun CommentInputBarPreview() { + CommentInputBar( + isCreating = false, + replyToComment = CommentCard( + commentId = 1, + postId = 1, + content = "这是一个回复评论的示例。", + commentDateTime = Date(), + authorId = 1, + authorName = "示例用户", + authorAvatar = "avatar1" + ), + onSendComment = {}, + onCancelReply = {} + ) +} + /** * 回复指示器 */ @@ -632,7 +655,7 @@ private fun ReplyIndicator( Text( text = "回复 ${comment.authorName}", style = MaterialTheme.typography.bodySmall, - fontFamily = LiteraryFontFamily, + fontFamily = TangyuanGeneralFontFamily, color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.weight(1f) ) 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 b857525..a8d19ff 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 @@ -23,12 +23,16 @@ 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.consumePositionChange import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged 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.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel @@ -38,11 +42,10 @@ 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 +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch /** * 图片详情页面 - 以图片为主的展示界面 @@ -60,8 +63,15 @@ fun ImageDetailScreen( animatedContentScope: AnimatedContentScope? = null ) { val state by viewModel.state.collectAsState() - - // 加载帖子详情 + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.saveMessage) { + state.saveMessage?.let { message -> + snackbarHostState.showSnackbar(message = message, duration = SnackbarDuration.Short) + viewModel.clearSaveMessage() + } + } + LaunchedEffect(postId) { viewModel.loadPostDetail(postId) } @@ -88,8 +98,14 @@ fun ImageDetailScreen( ImageDetailTopBar( onBackClick = onBackClick, currentIndex = pagerState.currentPage + 1, - totalCount = imageUUIDs.size + totalCount = imageUUIDs.size, + onSaveClick = { + val currentImageUrl = + "${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[pagerState.currentPage]}.jpg" + viewModel.saveCurrentImage(currentImageUrl) + } ) + Spacer(modifier = Modifier.height(16.dp)) // 图片轮播区域 Box( @@ -108,6 +124,8 @@ fun ImageDetailScreen( ) } } + + Spacer(modifier = Modifier.height(16.dp)) // 底部内容区域(模糊遮罩) BottomContentOverlay( @@ -118,14 +136,20 @@ fun ImageDetailScreen( animatedContentScope = animatedContentScope ) } + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + ) } } - + // 加载状态 if (state.isLoading && state.postCard == null) { LoadingContent() } - + // 错误状态 state.error?.let { error -> ErrorContent( @@ -160,7 +184,7 @@ private fun BackgroundBlurredImage( alpha = 0.3f ) } - + // 渐变遮罩 Box( modifier = Modifier @@ -185,7 +209,8 @@ private fun BackgroundBlurredImage( private fun ImageDetailTopBar( onBackClick: () -> Unit, currentIndex: Int, - totalCount: Int + totalCount: Int, + onSaveClick: () -> Unit ) { TopAppBar( title = { @@ -206,15 +231,22 @@ private fun ImageDetailTopBar( ) } }, + actions = { + // 保存图片按钮 + IconButton(onClick = onSaveClick) { + Icon( + imageVector = Icons.Default.Download, + contentDescription = "保存图片", + tint = Color.White + ) + } + }, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent ) ) } -/** - * 图片轮播组件 - */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun ImagePager( @@ -224,9 +256,13 @@ private fun ImagePager( sharedTransitionScope: SharedTransitionScope? = null, animatedContentScope: AnimatedContentScope? = null ) { + // 用于控制Pager是否允许左右滑动 + var canScroll by remember { mutableStateOf(true) } + HorizontalPager( state = pagerState, - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + userScrollEnabled = canScroll // ✅ 当缩放时禁用Pager滑动 ) { page -> ZoomableImage( imageUrl = "${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[page]}.jpg", @@ -234,14 +270,14 @@ private fun ImagePager( imageIndex = page, contentDescription = "图片 ${page + 1}", sharedTransitionScope = sharedTransitionScope, - animatedContentScope = animatedContentScope + animatedContentScope = animatedContentScope, + onScaleChanged = { newScale -> + canScroll = newScale <= 1.01f // 缩放>1时禁用pager滑动 + } ) } } -/** - * 可缩放的图片组件 - */ @OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun ZoomableImage( @@ -250,23 +286,45 @@ private fun ZoomableImage( imageIndex: Int, contentDescription: String, sharedTransitionScope: SharedTransitionScope? = null, - animatedContentScope: AnimatedContentScope? = null + animatedContentScope: AnimatedContentScope? = null, + onScaleChanged: (Float) -> Unit = {} ) { 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(), + modifier = Modifier + .fillMaxSize() + // ✅ 缩放 & 平移逻辑 + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + val newScale = (scale * zoom).coerceIn(1f, 5f) + val maxX = (newScale - 1f) * 400f + val maxY = (newScale - 1f) * 400f + val newOffset = Offset( + x = (offset.x + pan.x).coerceIn(-maxX, maxX), + y = (offset.y + pan.y).coerceIn(-maxY, maxY) + ) + + scale = newScale + offset = newOffset + onScaleChanged(newScale) + } + } + // ✅ 双击缩放 + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { + if (scale > 1f) { + scale = 1f + offset = Offset.Zero + } else { + scale = 2f + } + onScaleChanged(scale) + } + ) + }, contentAlignment = Alignment.Center ) { AsyncImage( @@ -275,43 +333,34 @@ private fun ZoomableImage( .crossfade(true) .build(), contentDescription = contentDescription, + contentScale = ContentScale.Fit, modifier = Modifier .fillMaxSize() - .let { mod -> + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offset.x + translationY = offset.y + } + .then( if (sharedTransitionScope != null && animatedContentScope != null) { with(sharedTransitionScope) { - mod.sharedElement( + Modifier.sharedElement( rememberSharedContentState(key = "post_image_${postId}_${imageIndex}"), animatedVisibilityScope = animatedContentScope, boundsTransform = { _, _ -> tween(durationMillis = 400, easing = FastOutSlowInEasing) - }, - placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize, - renderInOverlayDuringTransition = false + } ) } - } else mod - } - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = offset.x, - translationY = offset.y + } else Modifier ) - .transformable(state = transformableState) - .pointerInput(Unit) { - detectTapGestures( - onDoubleTap = { - scale = if (scale > 1f) 1f else 2f - offset = Offset.Zero - } - ) - }, - contentScale = ContentScale.Fit ) } } + + /** * 底部内容遮罩 */ @@ -326,7 +375,7 @@ private fun BottomContentOverlay( ) { var offsetY by remember { mutableFloatStateOf(0f) } val swipeThreshold = -100f // 上滑超过100px触发切换 - + Box( modifier = Modifier .fillMaxWidth() @@ -358,17 +407,13 @@ private fun BottomContentOverlay( .fillMaxWidth() .padding(20.dp) ) { - // 作者信息 PostAuthorInfo( postCard = postCard, onAuthorClick = onAuthorClick, sharedTransitionScope = sharedTransitionScope, animatedContentScope = animatedContentScope ) - Spacer(modifier = Modifier.height(16.dp)) - - // 文章内容 Text( text = postCard.textContent.withPanguSpacing(), style = MaterialTheme.typography.bodyMedium.copy( @@ -379,10 +424,7 @@ private fun BottomContentOverlay( maxLines = 4, overflow = TextOverflow.Ellipsis ) - Spacer(modifier = Modifier.height(16.dp)) - - // 分类和时间 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -401,7 +443,6 @@ private fun BottomContentOverlay( fontWeight = FontWeight.Medium ) } - Text( text = postCard.getTimeDisplayText(), style = MaterialTheme.typography.bodySmall, @@ -409,10 +450,7 @@ private fun BottomContentOverlay( color = Color.White.copy(alpha = 0.8f) ) } - Spacer(modifier = Modifier.height(20.dp)) - - // 上滑提示 - 放在最下面居中 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center @@ -467,9 +505,7 @@ private fun PostAuthorInfo( .clip(CircleShape), contentScale = ContentScale.Crop ) - Spacer(modifier = Modifier.width(12.dp)) - Column { Text( text = postCard.authorName.withPanguSpacing(), @@ -489,7 +525,6 @@ private fun PostAuthorInfo( } } else Modifier ) - if (postCard.authorBio.isNotBlank()) { Text( text = postCard.authorBio.withPanguSpacing(), @@ -557,7 +592,6 @@ private fun ErrorContent( tint = Color.White, modifier = Modifier.size(48.dp) ) - Text( text = "加载失败", style = MaterialTheme.typography.headlineSmall, @@ -565,7 +599,6 @@ private fun ErrorContent( color = Color.White, fontWeight = FontWeight.SemiBold ) - Text( text = message, style = MaterialTheme.typography.bodyMedium, @@ -573,7 +606,6 @@ private fun ErrorContent( color = Color.White.copy(alpha = 0.8f), textAlign = TextAlign.Center ) - Button( onClick = onRetry, colors = ButtonDefaults.buttonColors( 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 205a69a..f5e8f8e 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 @@ -530,7 +530,7 @@ private fun CommentSectionHeader(commentCount: Int, isLoading: Boolean = false) Text( text = "评论 ($commentCount)", style = MaterialTheme.typography.titleMedium, - fontFamily = LiteraryFontFamily, + fontFamily = TangyuanGeneralFontFamily, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold ) diff --git a/app/src/main/java/com/qingshuige/tangyuan/utils/ImageSaveUtils.kt b/app/src/main/java/com/qingshuige/tangyuan/utils/ImageSaveUtils.kt new file mode 100644 index 0000000..bc5c4c3 --- /dev/null +++ b/app/src/main/java/com/qingshuige/tangyuan/utils/ImageSaveUtils.kt @@ -0,0 +1,97 @@ +package com.qingshuige.tangyuan.utils + +import android.content.ContentValues +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import coil.ImageLoader +import coil.request.ImageRequest +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +object ImageSaveUtils { + + suspend fun saveImageToGallery( + context: Context, + imageUrl: String, + fileName: String? = null + ): Result = withContext(Dispatchers.IO) { + try { + val imageLoader = ImageLoader(context) + val request = ImageRequest.Builder(context) + .data(imageUrl) + .build() + + val drawable = imageLoader.execute(request).drawable + val bitmap = (drawable as? BitmapDrawable)?.bitmap + ?: return@withContext Result.failure(Exception("无法获取图片")) + + val displayName = fileName ?: "Tangyuan_${System.currentTimeMillis()}.jpg" + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + saveImageToMediaStore(context, bitmap, displayName) + } else { + saveImageToExternalStorage(bitmap, displayName) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun saveImageToMediaStore( + context: Context, + bitmap: Bitmap, + displayName: String + ): Result { + val contentValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, displayName) + put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") + put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Tangyuan") + } + + val uri = context.contentResolver.insert( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + contentValues + ) ?: return Result.failure(Exception("无法创建文件")) + + return try { + context.contentResolver.openOutputStream(uri)?.use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) + } + Result.success("图片已保存到相册") + } catch (e: IOException) { + Result.failure(e) + } + } + + private fun saveImageToExternalStorage( + bitmap: Bitmap, + displayName: String + ): Result { + val picturesDir = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + "Tangyuan" + ) + + if (!picturesDir.exists()) { + picturesDir.mkdirs() + } + + val imageFile = File(picturesDir, displayName) + + return try { + FileOutputStream(imageFile).use { outputStream -> + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) + } + Result.success("图片已保存到 ${imageFile.absolutePath}") + } catch (e: IOException) { + Result.failure(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/qingshuige/tangyuan/viewmodel/PostDetailViewModel.kt b/app/src/main/java/com/qingshuige/tangyuan/viewmodel/PostDetailViewModel.kt index b35d473..06ef217 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/viewmodel/PostDetailViewModel.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/viewmodel/PostDetailViewModel.kt @@ -1,5 +1,6 @@ package com.qingshuige.tangyuan.viewmodel +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.qingshuige.tangyuan.model.CommentCard @@ -7,7 +8,9 @@ import com.qingshuige.tangyuan.model.CreateCommentDto import com.qingshuige.tangyuan.model.PostCard import com.qingshuige.tangyuan.model.PostDetailState import com.qingshuige.tangyuan.repository.PostDetailRepository +import com.qingshuige.tangyuan.utils.ImageSaveUtils import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,7 +20,8 @@ import javax.inject.Inject @HiltViewModel class PostDetailViewModel @Inject constructor( - private val postDetailRepository: PostDetailRepository + private val postDetailRepository: PostDetailRepository, + @ApplicationContext private val context: Context ) : ViewModel() { private val _state = MutableStateFlow(PostDetailState()) @@ -130,7 +134,8 @@ class PostDetailViewModel @Inject constructor( val createCommentDto = CreateCommentDto( postId = currentPostId.toLong(), content = content, - parentCommentId = if (parentCommentId == 0) null else parentCommentId.toLong() + parentCommentId = if (parentCommentId == 0) null else parentCommentId.toLong(), + userId = currentUserId.toLong() ) postDetailRepository.createComment(createCommentDto) @@ -284,4 +289,36 @@ class PostDetailViewModel @Inject constructor( currentPostId = 0 currentUserId = 0 } + + /** + * 保存当前图片到本地 + */ + fun saveCurrentImage(imageUrl: String) { + viewModelScope.launch { + try { + val result = ImageSaveUtils.saveImageToGallery(context, imageUrl) + result.onSuccess { message -> + _state.value = _state.value.copy( + error = null, + saveMessage = message + ) + }.onFailure { exception -> + _state.value = _state.value.copy( + error = exception.message ?: "保存图片失败" + ) + } + } catch (e: Exception) { + _state.value = _state.value.copy( + error = e.message ?: "保存图片失败" + ) + } + } + } + + /** + * 清除保存消息 + */ + fun clearSaveMessage() { + _state.value = _state.value.copy(saveMessage = null) + } } \ No newline at end of file diff --git a/app/src/main/java/com/qingshuige/tangyuan/viewmodel/UserViewModel.kt b/app/src/main/java/com/qingshuige/tangyuan/viewmodel/UserViewModel.kt index 9c0bf1e..de836fe 100644 --- a/app/src/main/java/com/qingshuige/tangyuan/viewmodel/UserViewModel.kt +++ b/app/src/main/java/com/qingshuige/tangyuan/viewmodel/UserViewModel.kt @@ -150,6 +150,7 @@ class UserViewModel @Inject constructor( val token = result["token"] if (token != null) { tokenManager.token = token + println("DEBUG: 手动登录成功,已保存token: ${token.take(20)}...") } // 登录成功,保存账号密码用于自动登录 tokenManager.setPhoneNumberAndPassword( @@ -162,8 +163,13 @@ class UserViewModel @Inject constructor( isLoggedIn = true, ) - // 登录成功后获取用户信息 - getCurrentUserFromToken() + // 确保token保存后再获取用户信息 + if (token != null) { + println("DEBUG: 手动登录后开始获取用户信息") + getCurrentUserFromToken() + } else { + println("DEBUG: 手动登录失败,未获取到token") + } } } }