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.graphics.Bitmap
import android.net.Uri 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.lazy.grid.itemsIndexed
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape 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.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -66,6 +71,8 @@ import com.inspection.camera.util.ImageProcessor
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -88,6 +95,23 @@ fun MergeScreen(
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var content by remember { mutableStateOf("") } var content by remember { mutableStateOf("") }
var showSaveDialog by remember { mutableStateOf(false) } 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) { LaunchedEffect(Unit) {
@@ -129,18 +153,51 @@ fun MergeScreen(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
MergeLayoutType.entries.forEach { layout -> MergeLayoutType.entries.forEach { layout ->
LayoutOption( LayoutOption(
layout = layout, layout = layout,
isSelected = layoutType == 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( LazyVerticalGrid(
columns = GridCells.Fixed(layoutType.cols), columns = GridCells.Fixed(layoutType.cols),
@@ -157,6 +214,10 @@ fun MergeScreen(
.aspectRatio(1f) .aspectRatio(1f)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(Color.LightGray) .background(Color.LightGray)
.clickable {
selectedImageIndex = index
imagePickerLauncher.launch("image/*")
}
) { ) {
AsyncImage( AsyncImage(
model = ImageRequest.Builder(context) model = ImageRequest.Builder(context)
@@ -183,6 +244,42 @@ fun MergeScreen(
modifier = Modifier.size(16.dp) 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 = { onClick = {
scope.launch { scope.launch {
previewBitmap = withContext(Dispatchers.Default) { 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( ImageProcessor.mergeImages(
imageItems, imageItems,
layoutType, layoutType,
@@ -340,6 +440,9 @@ private fun LayoutOption(
MergeLayoutType.Grid2x2 -> "2x2" MergeLayoutType.Grid2x2 -> "2x2"
MergeLayoutType.Grid1x3 -> "1+3" MergeLayoutType.Grid1x3 -> "1+3"
MergeLayoutType.Grid3x1 -> "3+1" MergeLayoutType.Grid3x1 -> "3+1"
MergeLayoutType.Grid1x2 -> "1+2"
MergeLayoutType.Grid2x1 -> "2+1"
MergeLayoutType.Grid1x1 -> "单图"
} }
Box( 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
}
}
}