fix: 修复回忆录序号问题;隐藏图片提示词,增加缩放和保存功能
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -54,7 +54,7 @@ fun FullTextReadingView(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "第${chapterOrderToDisplayIndex(chapter.orderIndex)}章",
|
||||
text = "第${index + 1}章",
|
||||
fontSize = AppTypography.bodyMedium,
|
||||
color = LightPurple,
|
||||
fontWeight = FontWeight.Medium,
|
||||
|
||||
@@ -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),
|
||||
.background(Color.Black.copy(alpha = 0.9f)),
|
||||
) {
|
||||
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 = image.description,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
)
|
||||
if (image.description.isNotBlank()) {
|
||||
Text(
|
||||
text = image.description,
|
||||
fontSize = AppTypography.bodyMedium,
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
}
|
||||
}
|
||||
|
||||
// 顶部栏:关闭 + 保存
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 32.dp),
|
||||
.fillMaxSize()
|
||||
.padding(8.dp),
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.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 ?: "保存失败"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user