feat: Implement "Save to Gallery" and enhance image/comment interactions

This commit introduces the ability for users to save images to their device's gallery and includes several enhancements to the image detail screen and comment section.

**Key Changes:**

*   **feat(Image Saving):**
    *   **ImageSaveUtils:** Added a new `ImageSaveUtils.kt` utility to handle saving images to the device gallery. It supports both modern (Android Q+) and legacy storage APIs.
    *   **Save Button:** Implemented a "Save" icon button in the `ImageDetailScreen` top bar, allowing users to download the currently viewed image.
    *   **Feedback:** The app now displays a `Snackbar` message (e.g., "Image saved to gallery") upon successful or failed save operations.

*   **feat(Image Detail Screen):**
    *   **Improved Zoom/Pan:** Reworked the zoom and pan logic in `ZoomableImage` for a smoother experience. The `HorizontalPager` is now disabled when an image is zoomed in to prevent accidental swiping.
    *   **Double-Tap to Zoom:** Added double-tap-to-zoom functionality on the `ImageDetailScreen`.

*   **refactor(Comments):**
    *   **Improved Reply UI:** The comment input bar now shows a preview of the comment being replied to (e.g., "Replying to User: This is the comment...").
    *   **Deprecated DTO Fields:** Marked `commentDateTime` and `imageGuid` in `CreateCommentDto` as deprecated, as they are now handled by the backend.

*   **refactor(Permissions):**
    *   Added `WRITE_EXTERNAL_STORAGE`, `READ_EXTERNAL_STORAGE`, and `READ_MEDIA_IMAGES` permissions to `AndroidManifest.xml` to support the new image saving feature across different Android versions.

*   **fix(Login):**
    *   Ensured that the user's profile information is fetched immediately after the login token is successfully saved, fixing a potential race condition.
This commit is contained in:
grtsinry43 2025-10-08 00:13:38 +08:00
parent a5041db384
commit 4325757404
Signed by: grtsinry43
GPG Key ID: F3305FB3A978C934
9 changed files with 292 additions and 82 deletions

View File

@ -3,6 +3,11 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<application
android:name=".TangyuanApplication"

View File

@ -91,7 +91,10 @@ data class PostDetailState(
// 评论输入状态
val isCreatingComment: Boolean = false,
val commentError: String? = null,
val replyToComment: CommentCard? = null
val replyToComment: CommentCard? = null,
// 图片保存状态
val saveMessage: String? = null
)
/**

View File

@ -3,8 +3,15 @@ package com.qingshuige.tangyuan.model
import java.util.Date
data class CreateCommentDto(
val commentDateTime: Date? = null,
@Deprecated(
message = "后台自动生成,无需传递",
replaceWith = ReplaceWith("null")
) val commentDateTime: Date? = null,
val content: String? = null,
@Deprecated(
message = "字段已废弃,无需传递",
replaceWith = ReplaceWith("null")
)
val imageGuid: String? = null,
val parentCommentId: Long? = 0,
val postId: Long = 0,

View File

@ -17,11 +17,13 @@ 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.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
@ -32,6 +34,7 @@ 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 java.util.Date
/**
* 评论项组件
@ -299,7 +302,7 @@ private fun CommentActions(
*/
@Composable
private fun CommentActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
icon: ImageVector,
count: Int = 0,
text: String = "",
isActive: Boolean,
@ -535,7 +538,7 @@ fun CommentInputBar(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Bottom,
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// 输入框
@ -545,7 +548,8 @@ fun CommentInputBar(
modifier = Modifier.weight(1f),
placeholder = {
Text(
text = if (replyToComment != null) "回复 ${replyToComment.authorName}" else "说点什么...",
text = if (replyToComment != null) "回复 ${replyToComment.authorName}" + ": " + {replyToComment.content.take(20) + if (replyToComment.content.length > 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)
)

View File

@ -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(
@ -109,6 +125,8 @@ fun ImageDetailScreen(
}
}
Spacer(modifier = Modifier.height(16.dp))
// 底部内容区域(模糊遮罩)
BottomContentOverlay(
postCard = postCard,
@ -118,6 +136,12 @@ fun ImageDetailScreen(
animatedContentScope = animatedContentScope
)
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
)
}
}
@ -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
)
.transformable(state = transformableState)
.pointerInput(Unit) {
detectTapGestures(
onDoubleTap = {
scale = if (scale > 1f) 1f else 2f
offset = Offset.Zero
}
)
},
contentScale = ContentScale.Fit
}
} else Modifier
)
)
}
}
/**
* 底部内容遮罩
*/
@ -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(

View File

@ -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
)

View File

@ -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<String> = 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<String> {
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<String> {
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)
}
}
}

View File

@ -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)
}
}

View File

@ -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,
)
// 登录成功后获取用户信息
// 确保token保存后再获取用户信息
if (token != null) {
println("DEBUG: 手动登录后开始获取用户信息")
getCurrentUserFromToken()
} else {
println("DEBUG: 手动登录失败未获取到token")
}
}
}
}