fix: 修复回忆录序号问题;隐藏图片提示词,增加缩放和保存功能

This commit is contained in:
yangshilin
2026-03-11 16:09:21 +08:00
parent 201dedb84c
commit f3d26c9d0e
5 changed files with 177 additions and 29 deletions

View File

@@ -36,24 +36,30 @@ import com.huaga.life_echo.utils.TextUtils
* - 空章节:显示柔和的提示,引导用户去聊天
* - 有内容章节:显示标题、状态、页数
*/
/**
* @param displayIndex 展示用的章节序号(从 1 开始)。传入时按列表位置动态显示,不传则按 order_index 推算(兼容旧逻辑)。
*/
@Composable
fun ChapterCard(
chapter: ChapterDto,
isEmpty: Boolean = false,
onClick: () -> Unit,
onGoChat: (() -> Unit)? = null,
displayIndex: Int? = null,
modifier: Modifier = Modifier
) {
if (isEmpty) {
EmptyChapterCard(
chapter = chapter,
onGoChat = onGoChat,
displayIndex = displayIndex,
modifier = modifier
)
} else {
FilledChapterCard(
chapter = chapter,
onClick = onClick,
displayIndex = displayIndex,
modifier = modifier
)
}
@@ -66,11 +72,12 @@ fun ChapterCard(
private fun FilledChapterCard(
chapter: ChapterDto,
onClick: () -> Unit,
displayIndex: Int? = null,
modifier: Modifier = Modifier
) {
var isExpanded by remember { mutableStateOf(false) }
val chapterDisplayIndex = chapterOrderToDisplayIndex(chapter.order_index)
val chapterDisplayIndex = displayIndex ?: chapterOrderToDisplayIndex(chapter.order_index)
val statusText = when (chapter.status) {
"completed" -> "已整理 · 约${estimatePageCount(chapter.content)}"
@@ -214,9 +221,10 @@ private fun FilledChapterCard(
private fun EmptyChapterCard(
chapter: ChapterDto,
onGoChat: (() -> Unit)? = null,
displayIndex: Int? = null,
modifier: Modifier = Modifier
) {
val chapterDisplayIndex = chapterOrderToDisplayIndex(chapter.order_index)
val chapterDisplayIndex = displayIndex ?: chapterOrderToDisplayIndex(chapter.order_index)
Card(
modifier = modifier.fillMaxWidth(),

View File

@@ -20,9 +20,13 @@ import com.huaga.life_echo.ui.theme.AppTypography
import com.huaga.life_echo.ui.theme.LightPurple
import com.huaga.life_echo.utils.TextUtils
/**
* @param displayIndex 展示用的章节序号(从 1 开始)。传入时与列表/全文阅读一致;不传则按 orderIndex 推算。
*/
@Composable
fun ChapterReadingView(
chapter: ChapterContentDto,
displayIndex: Int? = null,
modifier: Modifier = Modifier
) {
val blocks = remember(chapter.content, chapter.images) {
@@ -38,13 +42,15 @@ fun ChapterReadingView(
)
}
val chapterDisplayNum = displayIndex ?: chapterOrderToDisplayIndex(chapter.orderIndex)
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp)
) {
item {
Text(
text = "${chapterOrderToDisplayIndex(chapter.orderIndex)}",
text = "${chapterDisplayNum}",
fontSize = AppTypography.bodyMedium,
color = LightPurple,
modifier = Modifier.padding(bottom = 4.dp)

View File

@@ -54,7 +54,7 @@ fun FullTextReadingView(
}
Text(
text = "${chapterOrderToDisplayIndex(chapter.orderIndex)}",
text = "${index + 1}",
fontSize = AppTypography.bodyMedium,
color = LightPurple,
fontWeight = FontWeight.Medium,

View File

@@ -1,28 +1,56 @@
package com.huaga.life_echo.ui.components.memoir
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.provider.MediaStore
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
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.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage
import com.huaga.life_echo.network.models.ChapterImageDto
import com.huaga.life_echo.ui.theme.AppTypography
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URL
@Composable
fun MemoirImageViewerDialog(
image: ChapterImageDto,
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
@@ -30,28 +58,125 @@ fun MemoirImageViewerDialog(
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.9f))
.clickable(onClick = onDismiss),
contentAlignment = Alignment.Center,
.background(Color.Black.copy(alpha = 0.9f)),
) {
AsyncImage(
model = image.url,
contentDescription = image.description,
contentScale = ContentScale.Fit,
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer(
scaleX = scale,
scaleY = scale,
translationX = offset.x,
translationY = offset.y,
)
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(1f, 5f)
if (scale > 1f) {
offset += pan
} else {
offset = Offset.Zero
}
}
},
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = image.url,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
)
}
}
// 顶部栏:关闭 + 保存
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
)
if (image.description.isNotBlank()) {
Text(
text = image.description,
fontSize = AppTypography.bodyMedium,
color = Color.White.copy(alpha = 0.7f),
.padding(8.dp),
) {
IconButton(
onClick = onDismiss,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 32.dp),
)
.align(Alignment.TopStart)
.size(48.dp),
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "关闭",
tint = Color.White,
)
}
IconButton(
onClick = {
scope.launch {
val msg = saveImageToGallery(context, image.url)
withContext(Dispatchers.Main) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
}
},
modifier = Modifier
.align(Alignment.TopEnd)
.size(48.dp),
) {
Icon(
imageVector = Icons.Default.Download,
contentDescription = "保存到相册",
tint = Color.White,
)
}
}
}
}
}
private suspend fun saveImageToGallery(context: Context, url: String?): String =
withContext(Dispatchers.IO) {
if (url.isNullOrBlank()) return@withContext "图片地址无效"
try {
val connection = URL(url).openConnection().apply {
connectTimeout = 15_000
readTimeout = 15_000
}
connection.inputStream.use { input ->
val bytes = input.readBytes()
if (bytes.isEmpty()) return@withContext "图片数据为空"
val mimeType = connection.contentType?.substringBefore(";")?.trim() ?: "image/jpeg"
val isPng = mimeType.equals("image/png", ignoreCase = true)
val extension = if (isPng) "png" else "jpg"
val displayName = "memoir_${System.currentTimeMillis()}.$extension"
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
put(MediaStore.Images.Media.MIME_TYPE, mimeType)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/LifeEcho")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val uri = context.contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues,
) ?: return@withContext "保存失败"
context.contentResolver.openOutputStream(uri)?.use { out ->
out.write(bytes)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
context.contentResolver.update(uri, contentValues, null, null)
}
"已保存到相册"
}
} catch (e: Exception) {
e.message ?: "保存失败"
}
}

View File

@@ -248,7 +248,11 @@ fun MyMemoirScreen(
onBackClick = { viewModel.toggleFullTextReading() }
)
} else if (selectedChapter != null) {
// 章节阅读视图
// 章节阅读视图:序号与列表一致,按在 chapterDtos 中的位置
val selectedDisplayIndex = remember(selectedChapter, chapterDtos) {
val idx = chapterDtos.indexOfFirst { it.id == selectedChapter!!.id }
if (idx >= 0) idx + 1 else null
}
val chapterContent = remember(selectedChapter, chapterDtos) {
val chapterDto = chapterDtos.find { it.id == selectedChapter!!.id }
ChapterContentDto(
@@ -375,7 +379,7 @@ fun MyMemoirScreen(
}
}
ChapterReadingView(chapter = chapterContent)
ChapterReadingView(chapter = chapterContent, displayIndex = selectedDisplayIndex)
}
}
} else {
@@ -485,14 +489,19 @@ private fun MemoirTableOfContents(
)
}
// 章节列表(始终显示所有章节);大字模式下卡片间距更大
items(chapterDtos, key = { it.id }) { chapterDto ->
// 章节列表(始终显示所有章节);序号按列表位置动态显示,避免预设章节与新增章节混序
items(
count = chapterDtos.size,
key = { chapterDtos[it].id }
) { index ->
val chapterDto = chapterDtos[index]
val isEmpty = chapterDto.content.isBlank()
ChapterCard(
chapter = chapterDto,
isEmpty = isEmpty,
onClick = { onChapterClick(chapterDto) },
onGoChat = if (isEmpty) onNavigateToChat else null
onGoChat = if (isEmpty) onNavigateToChat else null,
displayIndex = index + 1
)
Spacer(modifier = Modifier.height(cardSpacing))
}