feat(puzzle merge): fix URI handling by converting to local path; ensure preview includes text overlays and layout changes

This commit is contained in:
2026-03-01 12:16:02 +08:00
parent 1896f42f1b
commit 4ed5368614

View File

@@ -2,6 +2,7 @@ package com.inspection.camera.ui.merge
import android.graphics.Bitmap
import android.net.Uri
// java.io.File and FileOutputStream will be referenced with fully qualified names to avoid ambiguity
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -22,10 +23,14 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -66,6 +71,8 @@ import com.inspection.camera.util.ImageProcessor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -88,6 +95,23 @@ fun MergeScreen(
var title by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") }
var showSaveDialog by remember { mutableStateOf(false) }
var selectedImageIndex by remember { mutableStateOf(-1) }
// 图片选择器
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
) { uris ->
if (uris.isNotEmpty()) {
if (selectedImageIndex >= 0 && selectedImageIndex < images.size) {
images[selectedImageIndex] = uris.first()
} else {
if (images.size < layoutType.maxImages) {
images.addAll(uris.take(layoutType.maxImages - images.size))
}
}
}
selectedImageIndex = -1
}
// 加载用户配置
LaunchedEffect(Unit) {
@@ -129,18 +153,51 @@ fun MergeScreen(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
MergeLayoutType.entries.forEach { layout ->
LayoutOption(
layout = layout,
isSelected = layoutType == layout,
onClick = { layoutType = layout }
onClick = {
layoutType = layout
if (images.size > layout.maxImages) {
while (images.size > layout.maxImages) {
images.removeAt(images.size - 1)
}
}
}
)
}
}
// 质量选择
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("质量:", style = MaterialTheme.typography.bodySmall)
ImageQuality.entries.forEach { quality ->
Box(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(if (imageQuality == quality) Primary else Color.LightGray)
.clickable { imageQuality = quality }
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(
text = quality.displayName,
color = if (imageQuality == quality) Color.White else Color.Black,
style = MaterialTheme.typography.bodySmall
)
}
}
}
// 图片网格
LazyVerticalGrid(
columns = GridCells.Fixed(layoutType.cols),
@@ -157,6 +214,10 @@ fun MergeScreen(
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(Color.LightGray)
.clickable {
selectedImageIndex = index
imagePickerLauncher.launch("image/*")
}
) {
AsyncImage(
model = ImageRequest.Builder(context)
@@ -183,6 +244,42 @@ fun MergeScreen(
modifier = Modifier.size(16.dp)
)
}
// 替换图标
Icon(
Icons.Default.Refresh,
contentDescription = "替换",
tint = Color.White,
modifier = Modifier
.align(Alignment.BottomEnd)
.size(32.dp)
.padding(4.dp)
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
.padding(4.dp)
)
}
}
// 添加图片按钮
if (images.size < layoutType.maxImages) {
item {
Box(
modifier = Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(Color.LightGray.copy(alpha = 0.5f))
.clickable {
imagePickerLauncher.launch("image/*")
},
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Add,
contentDescription = "添加图片",
tint = Color.Gray,
modifier = Modifier.size(48.dp)
)
}
}
}
}
@@ -224,7 +321,10 @@ fun MergeScreen(
onClick = {
scope.launch {
previewBitmap = withContext(Dispatchers.Default) {
val imageItems = images.map { ImageItem(uri = it, path = it.toString()) }
val imageItems = images.map { uri ->
val path = convertUriToPath(context, uri)
ImageItem(uri = uri, path = path ?: uri.toString())
}
ImageProcessor.mergeImages(
imageItems,
layoutType,
@@ -340,6 +440,9 @@ private fun LayoutOption(
MergeLayoutType.Grid2x2 -> "2x2"
MergeLayoutType.Grid1x3 -> "1+3"
MergeLayoutType.Grid3x1 -> "3+1"
MergeLayoutType.Grid1x2 -> "1+2"
MergeLayoutType.Grid2x1 -> "2+1"
MergeLayoutType.Grid1x1 -> "单图"
}
Box(
@@ -365,3 +468,19 @@ private fun LayoutOption(
}
}
}
// 将 Content Uri 拷贝到缓存目录,返回本地文件路径(可被 BitmapFactory.decodeFile 使用)
private suspend fun convertUriToPath(context: android.content.Context, uri: android.net.Uri): String? {
return withContext(Dispatchers.IO) {
try {
val input = context.contentResolver.openInputStream(uri) ?: return@withContext null
val tmpFile = java.io.File(context.cacheDir, "img_${System.nanoTime()}.jpg")
java.io.FileOutputStream(tmpFile).use { output ->
input.use { inStream -> inStream.copyTo(output) }
}
tmpFile.absolutePath
} catch (e: Exception) {
null
}
}
}