diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterCard.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterCard.kt index 3ce2861..0325364 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterCard.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterCard.kt @@ -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(), diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt index afebf7d..944f623 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/ChapterReadingView.kt @@ -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) diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt index 233f184..0c218dc 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/FullTextReadingView.kt @@ -54,7 +54,7 @@ fun FullTextReadingView( } Text( - text = "第${chapterOrderToDisplayIndex(chapter.orderIndex)}章", + text = "第${index + 1}章", fontSize = AppTypography.bodyMedium, color = LightPurple, fontWeight = FontWeight.Medium, diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirImageViewerDialog.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirImageViewerDialog.kt index 2f368fa..c9afe86 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirImageViewerDialog.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/components/memoir/MemoirImageViewerDialog.kt @@ -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 ?: "保存失败" + } + } diff --git a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt index 789f0eb..1757643 100644 --- a/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt +++ b/app-android/app/src/main/java/com/huaga/life_echo/ui/screens/MyMemoirScreen.kt @@ -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)) }