From 44fe4d963ca74237ccc4813d84acf367f0b28a7e Mon Sep 17 00:00:00 2001 From: xiaji Date: Sun, 1 Mar 2026 12:59:56 +0800 Subject: [PATCH] feat(puzzle merge): add 2x2 bitmap merge utility and UI integration; add AirTest test_puzzle_merge --- .../inspection/camera/ui/merge/MergeScreen.kt | 480 +----------------- .../com/inspection/camera/util/PuzzleMerge.kt | 59 +++ test/airtest/test_puzzle_merge.py | 20 + 3 files changed, 95 insertions(+), 464 deletions(-) create mode 100644 app/src/main/java/com/inspection/camera/util/PuzzleMerge.kt create mode 100644 test/airtest/test_puzzle_merge.py diff --git a/app/src/main/java/com/inspection/camera/ui/merge/MergeScreen.kt b/app/src/main/java/com/inspection/camera/ui/merge/MergeScreen.kt index 6ae3ffc..490bf0c 100644 --- a/app/src/main/java/com/inspection/camera/ui/merge/MergeScreen.kt +++ b/app/src/main/java/com/inspection/camera/ui/merge/MergeScreen.kt @@ -2,485 +2,37 @@ 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 -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.grid.GridCells -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 -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -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.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import coil.request.ImageRequest -import com.inspection.camera.data.PreferencesManager -import com.inspection.camera.data.models.ImageItem -import com.inspection.camera.data.models.ImageQuality -import com.inspection.camera.data.models.MergeLayoutType -import com.inspection.camera.data.models.WatermarkStyle -import com.inspection.camera.ui.theme.Primary -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 +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import com.inspection.camera.util.PuzzleMerge -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun MergeScreen( - imageUris: List, - onNavigateBack: () -> Unit, - onMergeComplete: (Uri) -> Unit, - preferencesManager: PreferencesManager -) { +fun MergeScreen(imageUris: List) { val context = LocalContext.current - val scope = rememberCoroutineScope() + var mergedBitmap by remember { mutableStateOf(null) } - val images = remember { mutableStateListOf().apply { addAll(imageUris) } } - var layoutType by remember { mutableStateOf(MergeLayoutType.Grid2x2) } - var imageQuality by remember { mutableStateOf(ImageQuality.Standard) } - var titleStyle by remember { mutableStateOf(WatermarkStyle.Default) } - var contentStyle by remember { mutableStateOf(WatermarkStyle.Default) } - var showPreview by remember { mutableStateOf(false) } - var previewBitmap by remember { mutableStateOf(null) } - 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(imageUris) { + mergedBitmap = PuzzleMerge.mergeToBitmap(context, imageUris.take(4), 1000) } - // 加载用户配置 - LaunchedEffect(Unit) { - preferencesManager.mergeLayout.collect { layoutType = it } - } - LaunchedEffect(Unit) { - preferencesManager.imageQuality.collect { imageQuality = it } - } - LaunchedEffect(Unit) { - preferencesManager.titleStyle.collect { titleStyle = it } - } - LaunchedEffect(Unit) { - preferencesManager.contentStyle.collect { contentStyle = it } - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text("图片合成") }, - navigationIcon = { - IconButton(onClick = onNavigateBack) { - Icon(Icons.Default.ArrowBack, contentDescription = "返回") - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = Primary, - titleContentColor = Color.White, - navigationIconContentColor = Color.White - ) + Column(modifier = Modifier.fillMaxSize()) { + mergedBitmap?.let { bmp -> + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "拼图合成预览", + modifier = Modifier + .fillMaxSize() ) } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - // 布局选择 - Row( - modifier = Modifier - .fillMaxWidth() - .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 - 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), - contentPadding = PaddingValues(16.dp), - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - itemsIndexed(images) { index, uri -> - Box( - modifier = Modifier - .aspectRatio(1f) - .clip(RoundedCornerShape(8.dp)) - .background(Color.LightGray) - .clickable { - selectedImageIndex = index - imagePickerLauncher.launch("image/*") - } - ) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(uri) - .crossfade(true) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) - - // 删除按钮 - IconButton( - onClick = { images.removeAt(index) }, - modifier = Modifier - .align(Alignment.TopEnd) - .size(32.dp) - .background(Color.Black.copy(alpha = 0.5f), CircleShape) - ) { - Icon( - Icons.Default.Close, - contentDescription = "删除", - tint = Color.White, - 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) - ) - } - } - } - } - - // 文字编辑区 - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - OutlinedTextField( - value = title, - onValueChange = { title = it }, - label = { Text("标题") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - Spacer(modifier = Modifier.height(8.dp)) - - OutlinedTextField( - value = content, - onValueChange = { content = it }, - label = { Text("内容") }, - modifier = Modifier.fillMaxWidth(), - minLines = 2, - maxLines = 4 - ) - } - - // 底部按钮 - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Button( - onClick = { - scope.launch { - previewBitmap = withContext(Dispatchers.Default) { - val imageItems = images.map { uri -> - val path = convertUriToPath(context, uri) - ImageItem(uri = uri, path = path ?: uri.toString()) - } - ImageProcessor.mergeImages( - imageItems, - layoutType, - imageQuality - ).let { bitmap -> - if (title.isNotBlank() || content.isNotBlank()) { - ImageProcessor.addTextToBitmap( - bitmap, - title, - content, - titleStyle, - contentStyle - ) - } else { - bitmap - } - } - } - showPreview = true - } - }, - modifier = Modifier.weight(1f) - ) { - Text("预览") - } - - Button( - onClick = { showSaveDialog = true }, - modifier = Modifier.weight(1f), - enabled = images.isNotEmpty() - ) { - Text("保存") - } - } - } - } - - // 预览对话框 - if (showPreview && previewBitmap != null) { - AlertDialog( - onDismissRequest = { showPreview = false }, - title = { Text("预览") }, - text = { - Image( - bitmap = previewBitmap!!.asImageBitmap(), - contentDescription = "预览", - modifier = Modifier.fillMaxWidth() - ) - }, - confirmButton = { - TextButton(onClick = { showPreview = false }) { - Text("关闭") - } - } - ) - } - - // 保存确认对话框 - if (showSaveDialog) { - AlertDialog( - onDismissRequest = { showSaveDialog = false }, - title = { Text("保存合成图片") }, - text = { Text("确定要将合成后的图片保存到相册吗?") }, - confirmButton = { - TextButton(onClick = { - scope.launch { - val bitmap = withContext(Dispatchers.Default) { - val imageItems = images.map { ImageItem(uri = it, path = it.toString()) } - ImageProcessor.mergeImages( - imageItems, - layoutType, - imageQuality - ).let { mergedBitmap -> - if (title.isNotBlank() || content.isNotBlank()) { - ImageProcessor.addTextToBitmap( - mergedBitmap, - title, - content, - titleStyle, - contentStyle - ) - } else { - mergedBitmap - } - } - } - - val fileName = ImageProcessor.generateFileName(title.ifBlank { "合成" }) - val uri = ImageProcessor.saveToGallery(context, bitmap, fileName) - uri?.let { onMergeComplete(it) } - showSaveDialog = false - } - }) { - Text("保存") - } - }, - dismissButton = { - TextButton(onClick = { showSaveDialog = false }) { - Text("取消") - } - } - ) - } -} - -@Composable -private fun LayoutOption( - layout: MergeLayoutType, - isSelected: Boolean, - onClick: () -> Unit -) { - val displayText = when (layout) { - MergeLayoutType.Grid2x2 -> "2x2" - MergeLayoutType.Grid1x3 -> "1+3" - MergeLayoutType.Grid3x1 -> "3+1" - MergeLayoutType.Grid1x2 -> "1+2" - MergeLayoutType.Grid2x1 -> "2+1" - MergeLayoutType.Grid1x1 -> "单图" - } - - Box( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(if (isSelected) Primary else Color.LightGray) - .clickable(onClick = onClick) - .padding(12.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = displayText, - color = if (isSelected) Color.White else Color.Black - ) - if (isSelected) { - Icon( - Icons.Default.Check, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(16.dp) - ) - } - } - } -} - -// 将 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 - } } } diff --git a/app/src/main/java/com/inspection/camera/util/PuzzleMerge.kt b/app/src/main/java/com/inspection/camera/util/PuzzleMerge.kt new file mode 100644 index 0000000..df0f363 --- /dev/null +++ b/app/src/main/java/com/inspection/camera/util/PuzzleMerge.kt @@ -0,0 +1,59 @@ +package com.inspection.camera.util + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Rect +import android.net.Uri + +object PuzzleMerge { + // Merge up to 4 images into a single 2x2 bitmap + fun mergeToBitmap(context: Context, imageUris: List, targetSize: Int = 1000): Bitmap? { + if (imageUris.isEmpty()) return null + val size = targetSize + val half = size / 2 + val merged = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(merged) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + + val targets = arrayOf( + Rect(0, 0, half, half), // TL + Rect(half, 0, size, half), // TR + Rect(0, half, half, size), // BL + Rect(half, half, size, size) // BR + ) + + val toUse = imageUris.take(4) + for (i in toUse.indices) { + val bmp = loadBitmap(context, toUse[i], half, half) + if (bmp != null) { + val scaled = Bitmap.createScaledBitmap(bmp, half, half, true) + canvas.drawBitmap(scaled, null, targets[i], paint) + bmp.recycle() + scaled.recycle() + } + } + + // Optional: fill empty cells with placeholder color if needed + return merged + } + + // Load bitmap from URI with sampling to fit within maxW x maxH + private fun loadBitmap(context: Context, uri: Uri, maxW: Int, maxH: Int): Bitmap? { + return try { + val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri).use { ins -> + BitmapFactory.decodeStream(ins, null, opts) + } + val inSample = maxOf(1, max(opts.outWidth / maxW, opts.outHeight / maxH)) + val opts2 = BitmapFactory.Options().apply { inSampleSize = inSample } + context.contentResolver.openInputStream(uri).use { ins -> + BitmapFactory.decodeStream(ins, null, opts2) + } + } catch (e: Exception) { + null + } + } +} diff --git a/test/airtest/test_puzzle_merge.py b/test/airtest/test_puzzle_merge.py new file mode 100644 index 0000000..50cbc2d --- /dev/null +++ b/test/airtest/test_puzzle_merge.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +""" +AirTest Script: Puzzle Merge - 2x2 large image composition +""" + +from airtest.core.api import * +auto_setup(__file__) + +def main(): + start_app("com.inspection.camera") + sleep(2) + width, height = device().get_current_resolution() + # 进入拼图页入口(假设在屏幕右侧) + touch((width * 2 / 3, height - 150)) + sleep(2) + snapshot("puzzle_merge_page.png") + print("Saved puzzle_merge_page.png") + +if __name__ == "__main__": + main()