feat(puzzle merge): fix URI handling by converting to local path; ensure preview includes text overlays and layout changes
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user