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"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <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 <application
android:name=".TangyuanApplication" android:name=".TangyuanApplication"

View File

@ -91,7 +91,10 @@ data class PostDetailState(
// 评论输入状态 // 评论输入状态
val isCreatingComment: Boolean = false, val isCreatingComment: Boolean = false,
val commentError: String? = null, 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 import java.util.Date
data class CreateCommentDto( data class CreateCommentDto(
val commentDateTime: Date? = null, @Deprecated(
message = "后台自动生成,无需传递",
replaceWith = ReplaceWith("null")
) val commentDateTime: Date? = null,
val content: String? = null, val content: String? = null,
@Deprecated(
message = "字段已废弃,无需传递",
replaceWith = ReplaceWith("null")
)
val imageGuid: String? = null, val imageGuid: String? = null,
val parentCommentId: Long? = 0, val parentCommentId: Long? = 0,
val postId: 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage 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.TangyuanGeneralFontFamily
import com.qingshuige.tangyuan.ui.theme.TangyuanShapes import com.qingshuige.tangyuan.ui.theme.TangyuanShapes
import com.qingshuige.tangyuan.utils.withPanguSpacing import com.qingshuige.tangyuan.utils.withPanguSpacing
import java.util.Date
/** /**
* 评论项组件 * 评论项组件
@ -299,7 +302,7 @@ private fun CommentActions(
*/ */
@Composable @Composable
private fun CommentActionButton( private fun CommentActionButton(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: ImageVector,
count: Int = 0, count: Int = 0,
text: String = "", text: String = "",
isActive: Boolean, isActive: Boolean,
@ -535,7 +538,7 @@ fun CommentInputBar(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
// 输入框 // 输入框
@ -545,7 +548,8 @@ fun CommentInputBar(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
placeholder = { placeholder = {
Text( 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, fontFamily = LiteraryFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant 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(
text = "回复 ${comment.authorName}", text = "回复 ${comment.authorName}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontFamily = LiteraryFontFamily, fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.tertiary, color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.weight(1f) 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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer 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.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel 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.model.PostCard
import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily import com.qingshuige.tangyuan.ui.theme.LiteraryFontFamily
import com.qingshuige.tangyuan.ui.theme.TangyuanGeneralFontFamily import com.qingshuige.tangyuan.ui.theme.TangyuanGeneralFontFamily
import com.qingshuige.tangyuan.ui.theme.TangyuanShapes
import com.qingshuige.tangyuan.utils.withPanguSpacing import com.qingshuige.tangyuan.utils.withPanguSpacing
import com.qingshuige.tangyuan.viewmodel.PostDetailViewModel import com.qingshuige.tangyuan.viewmodel.PostDetailViewModel
import kotlin.math.max import kotlinx.coroutines.CoroutineScope
import kotlin.math.min import kotlinx.coroutines.launch
/** /**
* 图片详情页面 - 以图片为主的展示界面 * 图片详情页面 - 以图片为主的展示界面
@ -60,8 +63,15 @@ fun ImageDetailScreen(
animatedContentScope: AnimatedContentScope? = null animatedContentScope: AnimatedContentScope? = null
) { ) {
val state by viewModel.state.collectAsState() 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) { LaunchedEffect(postId) {
viewModel.loadPostDetail(postId) viewModel.loadPostDetail(postId)
} }
@ -88,8 +98,14 @@ fun ImageDetailScreen(
ImageDetailTopBar( ImageDetailTopBar(
onBackClick = onBackClick, onBackClick = onBackClick,
currentIndex = pagerState.currentPage + 1, 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( Box(
@ -109,6 +125,8 @@ fun ImageDetailScreen(
} }
} }
Spacer(modifier = Modifier.height(16.dp))
// 底部内容区域(模糊遮罩) // 底部内容区域(模糊遮罩)
BottomContentOverlay( BottomContentOverlay(
postCard = postCard, postCard = postCard,
@ -118,6 +136,12 @@ fun ImageDetailScreen(
animatedContentScope = animatedContentScope animatedContentScope = animatedContentScope
) )
} }
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
)
} }
} }
@ -185,7 +209,8 @@ private fun BackgroundBlurredImage(
private fun ImageDetailTopBar( private fun ImageDetailTopBar(
onBackClick: () -> Unit, onBackClick: () -> Unit,
currentIndex: Int, currentIndex: Int,
totalCount: Int totalCount: Int,
onSaveClick: () -> Unit
) { ) {
TopAppBar( TopAppBar(
title = { title = {
@ -206,15 +231,22 @@ private fun ImageDetailTopBar(
) )
} }
}, },
actions = {
// 保存图片按钮
IconButton(onClick = onSaveClick) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = "保存图片",
tint = Color.White
)
}
},
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent containerColor = Color.Transparent
) )
) )
} }
/**
* 图片轮播组件
*/
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun ImagePager( private fun ImagePager(
@ -224,9 +256,13 @@ private fun ImagePager(
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null animatedContentScope: AnimatedContentScope? = null
) { ) {
// 用于控制Pager是否允许左右滑动
var canScroll by remember { mutableStateOf(true) }
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize(),
userScrollEnabled = canScroll // ✅ 当缩放时禁用Pager滑动
) { page -> ) { page ->
ZoomableImage( ZoomableImage(
imageUrl = "${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[page]}.jpg", imageUrl = "${TangyuanApplication.instance.bizDomain}images/${imageUUIDs[page]}.jpg",
@ -234,14 +270,14 @@ private fun ImagePager(
imageIndex = page, imageIndex = page,
contentDescription = "图片 ${page + 1}", contentDescription = "图片 ${page + 1}",
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope animatedContentScope = animatedContentScope,
onScaleChanged = { newScale ->
canScroll = newScale <= 1.01f // 缩放>1时禁用pager滑动
}
) )
} }
} }
/**
* 可缩放的图片组件
*/
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
private fun ZoomableImage( private fun ZoomableImage(
@ -250,23 +286,45 @@ private fun ZoomableImage(
imageIndex: Int, imageIndex: Int,
contentDescription: String, contentDescription: String,
sharedTransitionScope: SharedTransitionScope? = null, sharedTransitionScope: SharedTransitionScope? = null,
animatedContentScope: AnimatedContentScope? = null animatedContentScope: AnimatedContentScope? = null,
onScaleChanged: (Float) -> Unit = {}
) { ) {
var scale by remember { mutableFloatStateOf(1f) } var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) } 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( 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 contentAlignment = Alignment.Center
) { ) {
AsyncImage( AsyncImage(
@ -275,43 +333,34 @@ private fun ZoomableImage(
.crossfade(true) .crossfade(true)
.build(), .build(),
contentDescription = contentDescription, contentDescription = contentDescription,
contentScale = ContentScale.Fit,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.let { mod -> .graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x
translationY = offset.y
}
.then(
if (sharedTransitionScope != null && animatedContentScope != null) { if (sharedTransitionScope != null && animatedContentScope != null) {
with(sharedTransitionScope) { with(sharedTransitionScope) {
mod.sharedElement( Modifier.sharedElement(
rememberSharedContentState(key = "post_image_${postId}_${imageIndex}"), rememberSharedContentState(key = "post_image_${postId}_${imageIndex}"),
animatedVisibilityScope = animatedContentScope, animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ -> boundsTransform = { _, _ ->
tween(durationMillis = 400, easing = FastOutSlowInEasing) tween(durationMillis = 400, easing = FastOutSlowInEasing)
}, }
placeHolderSize = SharedTransitionScope.PlaceHolderSize.animatedSize,
renderInOverlayDuringTransition = false
) )
} }
} else mod } else Modifier
}
.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
) )
} }
} }
/** /**
* 底部内容遮罩 * 底部内容遮罩
*/ */
@ -358,17 +407,13 @@ private fun BottomContentOverlay(
.fillMaxWidth() .fillMaxWidth()
.padding(20.dp) .padding(20.dp)
) { ) {
// 作者信息
PostAuthorInfo( PostAuthorInfo(
postCard = postCard, postCard = postCard,
onAuthorClick = onAuthorClick, onAuthorClick = onAuthorClick,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedContentScope = animatedContentScope animatedContentScope = animatedContentScope
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// 文章内容
Text( Text(
text = postCard.textContent.withPanguSpacing(), text = postCard.textContent.withPanguSpacing(),
style = MaterialTheme.typography.bodyMedium.copy( style = MaterialTheme.typography.bodyMedium.copy(
@ -379,10 +424,7 @@ private fun BottomContentOverlay(
maxLines = 4, maxLines = 4,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// 分类和时间
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
@ -401,7 +443,6 @@ private fun BottomContentOverlay(
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
} }
Text( Text(
text = postCard.getTimeDisplayText(), text = postCard.getTimeDisplayText(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@ -409,10 +450,7 @@ private fun BottomContentOverlay(
color = Color.White.copy(alpha = 0.8f) color = Color.White.copy(alpha = 0.8f)
) )
} }
Spacer(modifier = Modifier.height(20.dp)) Spacer(modifier = Modifier.height(20.dp))
// 上滑提示 - 放在最下面居中
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
@ -467,9 +505,7 @@ private fun PostAuthorInfo(
.clip(CircleShape), .clip(CircleShape),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column { Column {
Text( Text(
text = postCard.authorName.withPanguSpacing(), text = postCard.authorName.withPanguSpacing(),
@ -489,7 +525,6 @@ private fun PostAuthorInfo(
} }
} else Modifier } else Modifier
) )
if (postCard.authorBio.isNotBlank()) { if (postCard.authorBio.isNotBlank()) {
Text( Text(
text = postCard.authorBio.withPanguSpacing(), text = postCard.authorBio.withPanguSpacing(),
@ -557,7 +592,6 @@ private fun ErrorContent(
tint = Color.White, tint = Color.White,
modifier = Modifier.size(48.dp) modifier = Modifier.size(48.dp)
) )
Text( Text(
text = "加载失败", text = "加载失败",
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
@ -565,7 +599,6 @@ private fun ErrorContent(
color = Color.White, color = Color.White,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
Text( Text(
text = message, text = message,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@ -573,7 +606,6 @@ private fun ErrorContent(
color = Color.White.copy(alpha = 0.8f), color = Color.White.copy(alpha = 0.8f),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Button( Button(
onClick = onRetry, onClick = onRetry,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(

View File

@ -530,7 +530,7 @@ private fun CommentSectionHeader(commentCount: Int, isLoading: Boolean = false)
Text( Text(
text = "评论 ($commentCount)", text = "评论 ($commentCount)",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontFamily = LiteraryFontFamily, fontFamily = TangyuanGeneralFontFamily,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold 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 package com.qingshuige.tangyuan.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.qingshuige.tangyuan.model.CommentCard 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.PostCard
import com.qingshuige.tangyuan.model.PostDetailState import com.qingshuige.tangyuan.model.PostDetailState
import com.qingshuige.tangyuan.repository.PostDetailRepository import com.qingshuige.tangyuan.repository.PostDetailRepository
import com.qingshuige.tangyuan.utils.ImageSaveUtils
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -17,7 +20,8 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class PostDetailViewModel @Inject constructor( class PostDetailViewModel @Inject constructor(
private val postDetailRepository: PostDetailRepository private val postDetailRepository: PostDetailRepository,
@ApplicationContext private val context: Context
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(PostDetailState()) private val _state = MutableStateFlow(PostDetailState())
@ -130,7 +134,8 @@ class PostDetailViewModel @Inject constructor(
val createCommentDto = CreateCommentDto( val createCommentDto = CreateCommentDto(
postId = currentPostId.toLong(), postId = currentPostId.toLong(),
content = content, content = content,
parentCommentId = if (parentCommentId == 0) null else parentCommentId.toLong() parentCommentId = if (parentCommentId == 0) null else parentCommentId.toLong(),
userId = currentUserId.toLong()
) )
postDetailRepository.createComment(createCommentDto) postDetailRepository.createComment(createCommentDto)
@ -284,4 +289,36 @@ class PostDetailViewModel @Inject constructor(
currentPostId = 0 currentPostId = 0
currentUserId = 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"] val token = result["token"]
if (token != null) { if (token != null) {
tokenManager.token = token tokenManager.token = token
println("DEBUG: 手动登录成功已保存token: ${token.take(20)}...")
} }
// 登录成功,保存账号密码用于自动登录 // 登录成功,保存账号密码用于自动登录
tokenManager.setPhoneNumberAndPassword( tokenManager.setPhoneNumberAndPassword(
@ -162,8 +163,13 @@ class UserViewModel @Inject constructor(
isLoggedIn = true, isLoggedIn = true,
) )
// 登录成功后获取用户信息 // 确保token保存后再获取用户信息
getCurrentUserFromToken() if (token != null) {
println("DEBUG: 手动登录后开始获取用户信息")
getCurrentUserFromToken()
} else {
println("DEBUG: 手动登录失败未获取到token")
}
} }
} }
} }