fix: 修复回忆录序号问题;隐藏图片提示词,增加缩放和保存功能
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 ?: "保存失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user