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 @Composable
fun ChapterCard( fun ChapterCard(
chapter: ChapterDto, chapter: ChapterDto,
isEmpty: Boolean = false, isEmpty: Boolean = false,
onClick: () -> Unit, onClick: () -> Unit,
onGoChat: (() -> Unit)? = null, onGoChat: (() -> Unit)? = null,
displayIndex: Int? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
if (isEmpty) { if (isEmpty) {
EmptyChapterCard( EmptyChapterCard(
chapter = chapter, chapter = chapter,
onGoChat = onGoChat, onGoChat = onGoChat,
displayIndex = displayIndex,
modifier = modifier modifier = modifier
) )
} else { } else {
FilledChapterCard( FilledChapterCard(
chapter = chapter, chapter = chapter,
onClick = onClick, onClick = onClick,
displayIndex = displayIndex,
modifier = modifier modifier = modifier
) )
} }
@@ -66,11 +72,12 @@ fun ChapterCard(
private fun FilledChapterCard( private fun FilledChapterCard(
chapter: ChapterDto, chapter: ChapterDto,
onClick: () -> Unit, onClick: () -> Unit,
displayIndex: Int? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var isExpanded by remember { mutableStateOf(false) } var isExpanded by remember { mutableStateOf(false) }
val chapterDisplayIndex = chapterOrderToDisplayIndex(chapter.order_index) val chapterDisplayIndex = displayIndex ?: chapterOrderToDisplayIndex(chapter.order_index)
val statusText = when (chapter.status) { val statusText = when (chapter.status) {
"completed" -> "已整理 · 约${estimatePageCount(chapter.content)}" "completed" -> "已整理 · 约${estimatePageCount(chapter.content)}"
@@ -214,9 +221,10 @@ private fun FilledChapterCard(
private fun EmptyChapterCard( private fun EmptyChapterCard(
chapter: ChapterDto, chapter: ChapterDto,
onGoChat: (() -> Unit)? = null, onGoChat: (() -> Unit)? = null,
displayIndex: Int? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val chapterDisplayIndex = chapterOrderToDisplayIndex(chapter.order_index) val chapterDisplayIndex = displayIndex ?: chapterOrderToDisplayIndex(chapter.order_index)
Card( Card(
modifier = modifier.fillMaxWidth(), 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.ui.theme.LightPurple
import com.huaga.life_echo.utils.TextUtils import com.huaga.life_echo.utils.TextUtils
/**
* @param displayIndex 展示用的章节序号(从 1 开始)。传入时与列表/全文阅读一致;不传则按 orderIndex 推算。
*/
@Composable @Composable
fun ChapterReadingView( fun ChapterReadingView(
chapter: ChapterContentDto, chapter: ChapterContentDto,
displayIndex: Int? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val blocks = remember(chapter.content, chapter.images) { val blocks = remember(chapter.content, chapter.images) {
@@ -38,13 +42,15 @@ fun ChapterReadingView(
) )
} }
val chapterDisplayNum = displayIndex ?: chapterOrderToDisplayIndex(chapter.orderIndex)
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp) contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp)
) { ) {
item { item {
Text( Text(
text = "${chapterOrderToDisplayIndex(chapter.orderIndex)}", text = "${chapterDisplayNum}",
fontSize = AppTypography.bodyMedium, fontSize = AppTypography.bodyMedium,
color = LightPurple, color = LightPurple,
modifier = Modifier.padding(bottom = 4.dp) modifier = Modifier.padding(bottom = 4.dp)

View File

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

View File

@@ -1,28 +1,56 @@
package com.huaga.life_echo.ui.components.memoir 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.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding 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.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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.huaga.life_echo.network.models.ChapterImageDto 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 @Composable
fun MemoirImageViewerDialog( fun MemoirImageViewerDialog(
image: ChapterImageDto, image: ChapterImageDto,
onDismiss: () -> Unit, onDismiss: () -> Unit,
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var scale by remember { mutableFloatStateOf(1f) }
var offset by remember { mutableStateOf(Offset.Zero) }
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false), properties = DialogProperties(usePlatformDefaultWidth = false),
@@ -30,28 +58,125 @@ fun MemoirImageViewerDialog(
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = 0.9f)) .background(Color.Black.copy(alpha = 0.9f)),
.clickable(onClick = onDismiss),
contentAlignment = Alignment.Center,
) { ) {
AsyncImage( Box(
model = image.url, modifier = Modifier.fillMaxSize(),
contentDescription = image.description, contentAlignment = Alignment.Center,
contentScale = ContentScale.Fit, ) {
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 modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .padding(8.dp),
) ) {
if (image.description.isNotBlank()) { IconButton(
Text( onClick = onDismiss,
text = image.description,
fontSize = AppTypography.bodyMedium,
color = Color.White.copy(alpha = 0.7f),
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.TopStart)
.padding(bottom = 32.dp), .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() } onBackClick = { viewModel.toggleFullTextReading() }
) )
} else if (selectedChapter != null) { } 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 chapterContent = remember(selectedChapter, chapterDtos) {
val chapterDto = chapterDtos.find { it.id == selectedChapter!!.id } val chapterDto = chapterDtos.find { it.id == selectedChapter!!.id }
ChapterContentDto( ChapterContentDto(
@@ -375,7 +379,7 @@ fun MyMemoirScreen(
} }
} }
ChapterReadingView(chapter = chapterContent) ChapterReadingView(chapter = chapterContent, displayIndex = selectedDisplayIndex)
} }
} }
} else { } 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() val isEmpty = chapterDto.content.isBlank()
ChapterCard( ChapterCard(
chapter = chapterDto, chapter = chapterDto,
isEmpty = isEmpty, isEmpty = isEmpty,
onClick = { onChapterClick(chapterDto) }, onClick = { onChapterClick(chapterDto) },
onGoChat = if (isEmpty) onNavigateToChat else null onGoChat = if (isEmpty) onNavigateToChat else null,
displayIndex = index + 1
) )
Spacer(modifier = Modifier.height(cardSpacing)) Spacer(modifier = Modifier.height(cardSpacing))
} }