开发个安卓app,画板里可以绘制简单的图形,比如矩形、圆、三角形、直线、虚线等,可以添加文字,可以添加icon目录里的图列。以上都可以旋转缩放。绘制过程写入日志文件里。画完后,图片可以保存到本地。
D:\dev\tools\Everything-SDK 用三丰云的everything mcp搜索免费云服务器已经安装好的Gradle,生成带签名的apk文件,发行版。
& "D:\ide\MavenRepository\wrapper\dists\gradle-9.2.0-bin\11i5gvueggl8a5cioxuftxrik\gradle-9.2.0\bin\gradle.bat" assembleRelease
项目已有基础代码,接下来修复关键问题、复制图标资源、更新 Gradle,并构建安装。
gradle\wrapper\gradle-wrapper.properties
bash
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
app\src\main\java\com\accident\scene\diagram\ElementDimensions.kt
bash
package com.accident.scene.diagram
import androidx.compose.ui.geometry.Offset
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
object ElementDimensions {
data class PixelSize(val width: Float, val height: Float) {
val hasHeight: Boolean get() = height > 0f
}
fun getPixelSize(element: DrawElement): PixelSize {
return when (element) {
is DrawElement.Rectangle -> PixelSize(
element.width * element.scale,
element.height * element.scale
)
is DrawElement.IconElement -> PixelSize(
element.width * element.scale,
element.height * element.scale
)
is DrawElement.Circle -> {
val d = element.radius * 2f * element.scale
PixelSize(d, d)
}
is DrawElement.Triangle -> {
val s = element.size * element.scale
PixelSize(s, s)
}
is DrawElement.Line, is DrawElement.DashedLine -> {
val end = when (element) {
is DrawElement.Line -> element.endPosition
is DrawElement.DashedLine -> element.endPosition
else -> element.position
}
val length = sqrt(
(end.x - element.position.x) * (end.x - element.position.x) +
(end.y - element.position.y) * (end.y - element.position.y)
) * element.scale
PixelSize(length, 0f)
}
is DrawElement.TextElement -> PixelSize(
element.text.length * element.fontSize * 0.6f * element.scale,
element.fontSize * element.scale
)
}
}
fun pixelsToMeters(pixels: Float, pixelsPerMeter: Float): Float {
if (pixelsPerMeter <= 0f) return 0f
return pixels / pixelsPerMeter
}
fun metersToPixels(meters: Float, pixelsPerMeter: Float): Float {
return meters * pixelsPerMeter
}
fun applyRealDimensions(
element: DrawElement,
widthMeters: Float,
heightMeters: Float,
pixelsPerMeter: Float
): DrawElement {
if (pixelsPerMeter <= 0f) return element
val pxW = metersToPixels(widthMeters, pixelsPerMeter)
val pxH = metersToPixels(heightMeters, pixelsPerMeter)
return when (element) {
is DrawElement.Rectangle -> resizeFromCenter(element, pxW, pxH) { w, h, pos ->
element.copy(width = w, height = h, scale = 1f, position = pos)
}
is DrawElement.IconElement -> resizeFromCenter(element, pxW, pxH) { w, h, pos ->
element.copy(width = w, height = h, scale = 1f, position = pos)
}
is DrawElement.Circle -> {
val diameter = if (heightMeters > 0f) minOf(pxW, pxH) else pxW
element.copy(radius = diameter / 2f, scale = 1f)
}
is DrawElement.Triangle -> {
element.copy(size = pxW, scale = 1f)
}
is DrawElement.Line -> resizeLine(element, pxW)
is DrawElement.DashedLine -> resizeLine(element, pxW) as DrawElement.DashedLine
is DrawElement.TextElement -> {
val newFontSize = if (heightMeters > 0f) pxH else pxW
element.copy(fontSize = newFontSize.coerceAtLeast(8f), scale = 1f)
}
}
}
private fun resizeFromCenter(
element: DrawElement,
pxW: Float,
pxH: Float,
copy: (Float, Float, Offset) -> DrawElement
): DrawElement {
val current = getPixelSize(element)
val cx = when (element) {
is DrawElement.Rectangle -> element.position.x + current.width / 2f
is DrawElement.IconElement -> element.position.x + current.width / 2f
else -> element.position.x
}
val cy = when (element) {
is DrawElement.Rectangle -> element.position.y + current.height / 2f
is DrawElement.IconElement -> element.position.y + current.height / 2f
else -> element.position.y
}
val w = pxW.coerceAtLeast(1f)
val h = pxH.coerceAtLeast(1f)
return copy(w, h, Offset(cx - w / 2f, cy - h / 2f))
}
private fun resizeLine(element: DrawElement, pxLength: Float): DrawElement {
val start = element.position
val end = when (element) {
is DrawElement.Line -> element.endPosition
is DrawElement.DashedLine -> element.endPosition
else -> start
}
val cx = (start.x + end.x) / 2f
val cy = (start.y + end.y) / 2f
val angle = atan2(end.y - start.y, end.x - start.x)
val half = pxLength.coerceAtLeast(1f) / 2f
val newStart = Offset(cx - cos(angle) * half, cy - sin(angle) * half)
val newEnd = Offset(cx + cos(angle) * half, cy + sin(angle) * half)
return when (element) {
is DrawElement.Line -> element.copy(
position = newStart,
endPosition = newEnd,
scale = 1f
)
is DrawElement.DashedLine -> element.copy(
position = newStart,
endPosition = newEnd,
scale = 1f
)
else -> element
}
}
}
app\src\main\java\com\accident\scene\diagram\DrawingViewModel.kt
bash
package com.accident.scene.diagram
import android.content.ContentValues
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.UUID
data class SaveResultMessage(
val imagePath: String,
val logPath: String
)
class DrawingViewModel : ViewModel() {
private val _state = MutableStateFlow(DrawingState())
val state: StateFlow<DrawingState> = _state.asStateFlow()
private val _saveResult = MutableStateFlow<SaveResultMessage?>(null)
val saveResult: StateFlow<SaveResultMessage?> = _saveResult.asStateFlow()
private var logger: DrawingLogger? = null
fun initLogger(context: Context) {
logger = DrawingLogger(context)
}
fun clearSaveResult() {
_saveResult.value = null
}
fun setTool(tool: DrawingTool) {
_state.update {
it.copy(
currentTool = tool,
selectedElementId = if (tool == DrawingTool.SELECT) it.selectedElementId else null,
pendingIconUri = if (tool == DrawingTool.ICON) it.pendingIconUri else null
)
}
}
fun setColor(color: Color) {
_state.update { it.copy(currentColor = color) }
}
fun setStrokeWidth(width: Float) {
_state.update { it.copy(currentStrokeWidth = width) }
}
fun onDrawStart(position: Offset) {
if (_state.value.currentTool == DrawingTool.SELECT || _state.value.currentTool == DrawingTool.TEXT || _state.value.currentTool == DrawingTool.ICON) {
return
}
_state.update {
it.copy(
isDrawing = true,
drawStartPosition = position
)
}
}
fun onDrawMove(position: Offset) {
// Preview handled in DrawingCanvas
}
fun onDrawEnd(endPosition: Offset) {
val currentState = _state.value
val startPosition = currentState.drawStartPosition ?: return
if (!currentState.isDrawing) return
val id = UUID.randomUUID().toString()
val element: DrawElement? = when (currentState.currentTool) {
DrawingTool.RECTANGLE -> {
val width = kotlin.math.abs(endPosition.x - startPosition.x)
val height = kotlin.math.abs(endPosition.y - startPosition.y)
val x = minOf(startPosition.x, endPosition.x)
val y = minOf(startPosition.y, endPosition.y)
DrawElement.Rectangle(
id = id,
position = Offset(x, y),
width = width,
height = height,
color = currentState.currentColor,
strokeWidth = currentState.currentStrokeWidth
)
}
DrawingTool.CIRCLE -> {
val radius = kotlin.math.sqrt(
(endPosition.x - startPosition.x) * (endPosition.x - startPosition.x) +
(endPosition.y - startPosition.y) * (endPosition.y - startPosition.y)
)
DrawElement.Circle(
id = id,
position = startPosition,
radius = radius,
color = currentState.currentColor,
strokeWidth = currentState.currentStrokeWidth
)
}
DrawingTool.TRIANGLE -> {
val size = kotlin.math.sqrt(
(endPosition.x - startPosition.x) * (endPosition.x - startPosition.x) +
(endPosition.y - startPosition.y) * (endPosition.y - startPosition.y)
)
DrawElement.Triangle(
id = id,
position = startPosition,
size = size,
color = currentState.currentColor,
strokeWidth = currentState.currentStrokeWidth
)
}
DrawingTool.LINE -> {
DrawElement.Line(
id = id,
position = startPosition,
endPosition = endPosition,
color = currentState.currentColor,
strokeWidth = currentState.currentStrokeWidth
)
}
DrawingTool.DASHED_LINE -> {
DrawElement.DashedLine(
id = id,
position = startPosition,
endPosition = endPosition,
color = currentState.currentColor,
strokeWidth = currentState.currentStrokeWidth
)
}
else -> null
}
element?.let {
_state.update { state ->
state.copy(
elements = state.elements + it,
isDrawing = false,
drawStartPosition = null
)
}
logger?.logAddElement(it)
}
}
fun addTextElement(position: Offset, text: String) {
val id = UUID.randomUUID().toString()
val element = DrawElement.TextElement(
id = id,
position = position,
text = text,
color = _state.value.currentColor
)
_state.update { it.copy(elements = it.elements + element) }
logger?.logAddElement(element)
}
fun setPendingIcon(iconUri: android.net.Uri) {
_state.update {
it.copy(pendingIconUri = iconUri, currentTool = DrawingTool.ICON)
}
}
fun cancelPendingIcon() {
_state.update { it.copy(pendingIconUri = null, currentTool = DrawingTool.SELECT) }
}
fun placePendingIconAt(tapCenter: Offset) {
val iconUri = _state.value.pendingIconUri ?: return
val iconSize = 80f
val id = UUID.randomUUID().toString()
val position = Offset(tapCenter.x - iconSize / 2f, tapCenter.y - iconSize / 2f)
val element = DrawElement.IconElement(
id = id,
position = position,
iconUri = iconUri
)
_state.update {
it.copy(
elements = it.elements + element,
selectedElementId = id,
currentTool = DrawingTool.SELECT,
pendingIconUri = null
)
}
logger?.logAddElement(element)
}
fun selectElement(elementId: String?) {
_state.update { it.copy(selectedElementId = elementId) }
}
fun getSelectedElement(): DrawElement? {
return _state.value.elements.find { it.id == _state.value.selectedElementId }
}
fun updateElementPosition(elementId: String, delta: Offset) {
var newPosition: Offset? = null
_state.update { state ->
state.copy(
elements = state.elements.map { element ->
if (element.id == elementId) {
when (element) {
is DrawElement.Rectangle -> {
newPosition = element.position + delta
element.copy(position = element.position + delta)
}
is DrawElement.Circle -> {
newPosition = element.position + delta
element.copy(position = element.position + delta)
}
is DrawElement.Triangle -> {
newPosition = element.position + delta
element.copy(position = element.position + delta)
}
is DrawElement.Line -> {
newPosition = element.position + delta
element.copy(
position = element.position + delta,
endPosition = element.endPosition + delta
)
}
is DrawElement.DashedLine -> {
newPosition = element.position + delta
element.copy(
position = element.position + delta,
endPosition = element.endPosition + delta
)
}
is DrawElement.TextElement -> {
newPosition = element.position + delta
element.copy(position = element.position + delta)
}
is DrawElement.IconElement -> {
newPosition = element.position + delta
element.copy(position = element.position + delta)
}
}
} else element
}
)
}
newPosition?.let { logger?.logMove(elementId, it) }
}
fun updateElementRotation(elementId: String, rotation: Float) {
_state.update { state ->
state.copy(
elements = state.elements.map { element ->
if (element.id == elementId) {
when (element) {
is DrawElement.Rectangle -> element.copy(rotation = rotation)
is DrawElement.Circle -> element.copy(rotation = rotation)
is DrawElement.Triangle -> element.copy(rotation = rotation)
is DrawElement.Line -> element.copy(rotation = rotation)
is DrawElement.DashedLine -> element.copy(rotation = rotation)
is DrawElement.TextElement -> element.copy(rotation = rotation)
is DrawElement.IconElement -> element.copy(rotation = rotation)
}
} else element
}
)
}
logger?.logRotate(elementId, rotation)
}
fun updateElementScale(elementId: String, scale: Float) {
_state.update { state ->
state.copy(
elements = state.elements.map { element ->
if (element.id == elementId) {
when (element) {
is DrawElement.Rectangle -> element.copy(scale = scale)
is DrawElement.Circle -> element.copy(scale = scale)
is DrawElement.Triangle -> element.copy(scale = scale)
is DrawElement.Line -> element.copy(scale = scale)
is DrawElement.DashedLine -> element.copy(scale = scale)
is DrawElement.TextElement -> element.copy(scale = scale)
is DrawElement.IconElement -> element.copy(scale = scale)
}
} else element
}
)
}
logger?.logScale(elementId, scale)
}
fun deleteSelectedElement() {
val selectedId = _state.value.selectedElementId ?: return
_state.update { state ->
state.copy(
elements = state.elements.filter { it.id != selectedId },
selectedElementId = null
)
}
logger?.logDelete(selectedId)
}
fun nudgeSelected(delta: Offset) {
val selectedId = _state.value.selectedElementId ?: return
updateElementPosition(selectedId, delta)
}
fun rotateSelectedBy(deltaRadians: Float) {
val selectedId = _state.value.selectedElementId ?: return
rotateElementBy(selectedId, deltaRadians)
}
fun rotateElementBy(elementId: String, deltaRadians: Float) {
val element = _state.value.elements.find { it.id == elementId } ?: return
updateElementRotation(elementId, element.rotation + deltaRadians)
}
fun scaleSelectedBy(factor: Float) {
val selectedId = _state.value.selectedElementId ?: return
scaleElementBy(selectedId, factor)
}
fun scaleElementBy(elementId: String, factor: Float) {
val element = _state.value.elements.find { it.id == elementId } ?: return
updateElementScale(elementId, (element.scale * factor).coerceIn(0.2f, 5f))
}
fun setSelectedRotationDegrees(degrees: Float) {
val selectedId = _state.value.selectedElementId ?: return
val radians = Math.toRadians(degrees.toDouble()).toFloat()
updateElementRotation(selectedId, radians)
}
fun setPixelsPerMeter(pixelsPerMeter: Float) {
if (pixelsPerMeter <= 0f) return
_state.update { it.copy(pixelsPerMeter = pixelsPerMeter) }
}
fun setSelectedRealDimensions(widthMeters: Float, heightMeters: Float) {
val selectedId = _state.value.selectedElementId ?: return
val ppm = _state.value.pixelsPerMeter
if (ppm <= 0f || widthMeters <= 0f) return
_state.update { state ->
state.copy(
elements = state.elements.map { element ->
if (element.id == selectedId) {
val h = if (heightMeters > 0f) heightMeters else widthMeters
ElementDimensions.applyRealDimensions(element, widthMeters, h, ppm)
} else element
}
)
}
logger?.log("RESIZE", "id=$selectedId, widthM=${widthMeters}m, heightM=${heightMeters}m, ppm=$ppm")
}
fun setBackground(uri: android.net.Uri) {
_state.update { it.copy(backgroundUri = uri) }
}
fun saveDrawing(
context: Context,
width: Int,
height: Int,
density: androidx.compose.ui.unit.Density
) {
if (width <= 0 || height <= 0) return
viewModelScope.launch {
withContext(Dispatchers.IO) {
val iconBitmaps = loadIconBitmaps(context, _state.value.elements)
val backgroundBitmap = loadAssetBitmap(context, _state.value.backgroundUri)
val bitmap = CanvasBitmapRenderer.render(
elements = _state.value.elements,
width = width,
height = height,
iconBitmaps = iconBitmaps,
backgroundBitmap = backgroundBitmap,
density = density
)
saveImage(context, bitmap)
}
}
}
private suspend fun loadIconBitmaps(
context: Context,
elements: List<DrawElement>
): Map<String, Bitmap> = withContext(Dispatchers.IO) {
val iconElements = elements.filterIsInstance<DrawElement.IconElement>()
val result = mutableMapOf<String, Bitmap>()
val loader = ImageLoader(context)
for (element in iconElements) {
try {
val request = ImageRequest.Builder(context)
.data(element.iconUri)
.allowHardware(false)
.build()
val coilResult = loader.execute(request)
if (coilResult is coil.request.SuccessResult) {
result[element.id] = coilResult.drawable.toBitmap()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
result
}
private suspend fun loadAssetBitmap(context: Context, uri: android.net.Uri): Bitmap? =
withContext(Dispatchers.IO) {
try {
val loader = ImageLoader(context)
val request = ImageRequest.Builder(context)
.data(uri)
.allowHardware(false)
.build()
val coilResult = loader.execute(request)
if (coilResult is coil.request.SuccessResult) {
coilResult.drawable.toBitmap()
} else null
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private suspend fun saveImage(context: Context, bitmap: Bitmap) {
try {
val fileName = generateUniqueImageFileName()
val savedPath: String
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val relativeDir = Environment.DIRECTORY_PICTURES + "/AccidentSceneDiagram"
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, "image/png")
put(MediaStore.MediaColumns.RELATIVE_PATH, relativeDir)
}
val uri = context.contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
) ?: throw IllegalStateException("无法创建图片文件")
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
} ?: throw IllegalStateException("无法写入图片文件")
savedPath = "${Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)}" +
"${File.separator}AccidentSceneDiagram${File.separator}$fileName"
} else {
val appDir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
"AccidentSceneDiagram"
)
if (!appDir.exists()) appDir.mkdirs()
val file = File(appDir, fileName)
FileOutputStream(file).use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
}
savedPath = file.absolutePath
}
val logPath = logger?.logFilePath ?: "日志未初始化"
logger?.logSaveImage(savedPath)
withContext(Dispatchers.Main) {
_saveResult.value = SaveResultMessage(
imagePath = savedPath,
logPath = logPath
)
}
} catch (e: Exception) {
e.printStackTrace()
withContext(Dispatchers.Main) {
Toast.makeText(context, "保存失败: ${e.message}", Toast.LENGTH_LONG).show()
}
}
}
private fun generateUniqueImageFileName(): String {
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss_SSS", Locale.getDefault()).format(Date())
return "accident_diagram_$timestamp.png"
}
}
app\src\main\java\com\accident\scene\diagram\DrawingScreen.kt
bash
package com.accident.scene.diagram
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.accident.scene.diagram.ui.theme.AccidentSceneDiagramTheme
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import kotlin.math.PI
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DrawingScreen(
viewModel: DrawingViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
val saveResult by viewModel.saveResult.collectAsState()
val context = LocalContext.current
val density = LocalDensity.current
var showTextDialog by remember { mutableStateOf(false) }
var showIconPicker by remember { mutableStateOf(false) }
var showColorPicker by remember { mutableStateOf(false) }
var textInput by remember { mutableStateOf("") }
var canvasSize by remember { mutableStateOf(IntSize.Zero) }
var shiftPressed by remember { mutableStateOf(false) }
val canvasFocusRequester = remember { FocusRequester() }
val iconUris = remember { IconAssets.listIcons(context) }
val isATypeBackground = state.backgroundUri.toString().contains("A型图")
val colorOptions = listOf(
Color.Black, Color.Red, Color.Blue, Color.Green,
Color.Yellow, Color.Magenta, Color.Cyan, Color(0xFF795548)
)
LaunchedEffect(Unit) {
viewModel.initLogger(context)
canvasFocusRequester.requestFocus()
}
AccidentSceneDiagramTheme {
Scaffold(
topBar = {
TopAppBar(
title = { Text("事故现场绘图") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color(0xFF2196F3),
titleContentColor = Color.White
),
actions = {
IconButton(onClick = { showColorPicker = true }) {
Box(
modifier = Modifier
.size(24.dp)
.background(state.currentColor, CircleShape)
.border(1.dp, Color.White, CircleShape)
)
}
IconButton(onClick = {
viewModel.saveDrawing(
context = context,
width = canvasSize.width,
height = canvasSize.height,
density = density
)
}) {
Text("保存", color = Color.White)
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.White)
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
ToolButton("选择", state.currentTool == DrawingTool.SELECT) {
viewModel.setTool(DrawingTool.SELECT)
}
ToolButton("矩形", state.currentTool == DrawingTool.RECTANGLE) {
viewModel.setTool(DrawingTool.RECTANGLE)
}
ToolButton("圆形", state.currentTool == DrawingTool.CIRCLE) {
viewModel.setTool(DrawingTool.CIRCLE)
}
ToolButton("三角形", state.currentTool == DrawingTool.TRIANGLE) {
viewModel.setTool(DrawingTool.TRIANGLE)
}
ToolButton("直线", state.currentTool == DrawingTool.LINE) {
viewModel.setTool(DrawingTool.LINE)
}
ToolButton("虚线", state.currentTool == DrawingTool.DASHED_LINE) {
viewModel.setTool(DrawingTool.DASHED_LINE)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.White)
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
ToolButton("文字", state.currentTool == DrawingTool.TEXT) {
viewModel.setTool(DrawingTool.TEXT)
showTextDialog = true
}
ToolButton("图例", state.currentTool == DrawingTool.ICON || state.pendingIconUri != null) {
viewModel.setTool(DrawingTool.ICON)
showIconPicker = true
}
ToolButton("删除", false) {
viewModel.deleteSelectedElement()
}
ToolButton("A型图", isATypeBackground) {
viewModel.setBackground(Uri.parse("file:///android_asset/icon/A型图.jpg"))
}
ToolButton("B型图", !isATypeBackground) {
viewModel.setBackground(Uri.parse("file:///android_asset/icon/B型图.jpg"))
}
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxSize()
.focusRequester(canvasFocusRequester)
.focusable()
.onKeyEvent { event ->
when (event.key) {
Key.ShiftLeft, Key.ShiftRight -> {
shiftPressed = event.type == KeyEventType.KeyDown
false
}
else -> if (event.type != KeyEventType.KeyDown) false else when (event.key) {
Key.DirectionLeft -> {
viewModel.nudgeSelected(Offset(-8f, 0f)); true
}
Key.DirectionRight -> {
viewModel.nudgeSelected(Offset(8f, 0f)); true
}
Key.DirectionUp -> {
viewModel.nudgeSelected(Offset(0f, -8f)); true
}
Key.DirectionDown -> {
viewModel.nudgeSelected(Offset(0f, 8f)); true
}
Key.Q, Key.Comma -> {
viewModel.rotateSelectedBy(-5f * PI.toFloat() / 180f); true
}
Key.E, Key.Period -> {
viewModel.rotateSelectedBy(5f * PI.toFloat() / 180f); true
}
Key.Equals, Key.Plus -> {
viewModel.scaleSelectedBy(1.1f); true
}
Key.Minus -> {
viewModel.scaleSelectedBy(0.9f); true
}
Key.Backspace, Key.Delete -> {
viewModel.deleteSelectedElement(); true
}
Key.Escape -> {
if (state.pendingIconUri != null) {
viewModel.cancelPendingIcon(); true
} else false
}
else -> false
}
}
}
) {
DrawingCanvas(
modifier = Modifier.fillMaxSize(),
state = state,
onDrawStart = { viewModel.onDrawStart(it) },
onDrawMove = { viewModel.onDrawMove(it) },
onDrawEnd = { viewModel.onDrawEnd(it) },
onElementSelected = { viewModel.selectElement(it) },
onElementMoved = { id, offset -> viewModel.updateElementPosition(id, offset) },
onElementRotated = { id, rotation -> viewModel.updateElementRotation(id, rotation) },
onElementScaled = { id, scale -> viewModel.updateElementScale(id, scale) },
onElementRotatedBy = { id, delta -> viewModel.rotateElementBy(id, delta) },
onElementScaledBy = { id, factor -> viewModel.scaleElementBy(id, factor) },
onPlacePendingIcon = { viewModel.placePendingIconAt(it) },
shiftPressed = shiftPressed,
onCanvasInteraction = { canvasFocusRequester.requestFocus() },
onCanvasSizeChanged = { canvasSize = it }
)
if (state.pendingIconUri != null) {
Surface(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(8.dp),
color = Color(0xCC1565C0),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"点击画布放置图例",
color = Color.White,
style = MaterialTheme.typography.labelLarge
)
Spacer(Modifier.width(12.dp))
TextButton(onClick = { viewModel.cancelPendingIcon() }) {
Text("取消", color = Color.White)
}
}
}
}
if (state.selectedElementId != null) {
val selectedElement = state.elements.find { it.id == state.selectedElementId }
if (selectedElement != null) {
SelectedElementEditPanel(
element = selectedElement,
pixelsPerMeter = state.pixelsPerMeter,
strokeWidth = state.currentStrokeWidth,
onNudge = { viewModel.nudgeSelected(it) },
onRotateBy = { viewModel.rotateSelectedBy(it) },
onScaleBy = { viewModel.scaleSelectedBy(it) },
onRotationDegrees = { viewModel.setSelectedRotationDegrees(it) },
onPixelsPerMeter = { viewModel.setPixelsPerMeter(it) },
onRealDimensions = { w, h -> viewModel.setSelectedRealDimensions(w, h) },
onStrokeWidth = { viewModel.setStrokeWidth(it) },
modifier = Modifier.align(Alignment.BottomCenter)
)
}
}
}
}
}
}
if (showTextDialog) {
AlertDialog(
onDismissRequest = { showTextDialog = false },
title = { Text("输入文字") },
text = {
OutlinedTextField(
value = textInput,
onValueChange = { textInput = it },
label = { Text("文字内容") }
)
},
confirmButton = {
TextButton(onClick = {
if (textInput.isNotBlank()) {
viewModel.addTextElement(
position = androidx.compose.ui.geometry.Offset(200f, 200f),
text = textInput
)
textInput = ""
showTextDialog = false
}
}) {
Text("添加")
}
},
dismissButton = {
TextButton(onClick = { showTextDialog = false }) {
Text("取消")
}
}
)
}
if (showIconPicker) {
AlertDialog(
onDismissRequest = { showIconPicker = false },
title = { Text("选择图例 (${iconUris.size})") },
text = {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.height(360.dp)
) {
items(iconUris) { (name, uri) ->
Box(
modifier = Modifier
.padding(4.dp)
.size(90.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFFEEEEEE))
.clickable {
viewModel.setPendingIcon(uri)
showIconPicker = false
},
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
AsyncImage(
model = uri,
contentDescription = name,
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(4.dp)),
contentScale = ContentScale.Fit
)
Text(
name,
style = MaterialTheme.typography.labelSmall,
maxLines = 1
)
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showIconPicker = false }) {
Text("关闭")
}
}
)
}
if (showColorPicker) {
AlertDialog(
onDismissRequest = { showColorPicker = false },
title = { Text("选择颜色") },
text = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
colorOptions.forEach { color ->
Box(
modifier = Modifier
.size(40.dp)
.background(color, CircleShape)
.border(
if (state.currentColor == color) 3.dp else 1.dp,
if (state.currentColor == color) Color.Blue else Color.Gray,
CircleShape
)
.clickable {
viewModel.setColor(color)
showColorPicker = false
}
)
}
}
},
confirmButton = {
TextButton(onClick = { showColorPicker = false }) {
Text("关闭")
}
}
)
}
saveResult?.let { result ->
AlertDialog(
onDismissRequest = { viewModel.clearSaveResult() },
title = { Text("保存成功") },
text = {
Column {
Text("图片文件:", style = MaterialTheme.typography.labelMedium)
Text(result.imagePath, style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(12.dp))
Text("日志文件:", style = MaterialTheme.typography.labelMedium)
Text(result.logPath, style = MaterialTheme.typography.bodySmall)
}
},
confirmButton = {
TextButton(onClick = { viewModel.clearSaveResult() }) {
Text("确定")
}
}
)
}
}
private val DECIMAL_INPUT = Regex("^\\d*\\.?\\d*$")
private val SIGNED_DECIMAL_INPUT = Regex("^-?\\d*\\.?\\d*$")
@Composable
private fun SelectedElementEditPanel(
element: DrawElement,
pixelsPerMeter: Float,
strokeWidth: Float,
onNudge: (Offset) -> Unit,
onRotateBy: (Float) -> Unit,
onScaleBy: (Float) -> Unit,
onRotationDegrees: (Float) -> Unit,
onPixelsPerMeter: (Float) -> Unit,
onRealDimensions: (Float, Float) -> Unit,
onStrokeWidth: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val pixelSize = ElementDimensions.getPixelSize(element)
val showHeight = when (element) {
is DrawElement.Line, is DrawElement.DashedLine, is DrawElement.Circle, is DrawElement.Triangle -> false
else -> true
}
val widthLabel = when (element) {
is DrawElement.Line, is DrawElement.DashedLine -> "长度"
is DrawElement.Circle -> "直径"
is DrawElement.Triangle -> "边长"
else -> "长"
}
var rotationInput by remember(element.id) { mutableStateOf("") }
var scaleInput by remember { mutableStateOf("") }
var widthInput by remember(element.id) { mutableStateOf("") }
var heightInput by remember(element.id) { mutableStateOf("") }
LaunchedEffect(element.id, element.rotation, element.scale, pixelSize.width, pixelSize.height) {
rotationInput = String.format("%.1f", Math.toDegrees(element.rotation.toDouble()))
widthInput = formatDecimal(ElementDimensions.pixelsToMeters(pixelSize.width, pixelsPerMeter))
heightInput = if (showHeight && pixelSize.hasHeight) {
formatDecimal(ElementDimensions.pixelsToMeters(pixelSize.height, pixelsPerMeter))
} else ""
}
LaunchedEffect(element.id, pixelsPerMeter) {
scaleInput = formatDecimal(pixelsPerMeter)
}
fun applyRotation() {
rotationInput.toFloatOrNull()?.let(onRotationDegrees)
}
fun applyScale() {
scaleInput.toFloatOrNull()?.takeIf { it > 0f }?.let(onPixelsPerMeter)
}
fun applyDimensions() {
scaleInput.toFloatOrNull()?.takeIf { it > 0f }?.let(onPixelsPerMeter)
val ppm = scaleInput.toFloatOrNull()?.takeIf { it > 0f } ?: pixelsPerMeter
val w = widthInput.toFloatOrNull() ?: return
val h = if (showHeight) heightInput.toFloatOrNull() ?: w else w
if (w > 0f && ppm > 0f) {
onRealDimensions(w, h)
}
}
val activePpm = scaleInput.toFloatOrNull()?.takeIf { it > 0f } ?: pixelsPerMeter
val previewPxW = widthInput.toFloatOrNull()?.let {
ElementDimensions.metersToPixels(it, activePpm)
} ?: pixelSize.width
val previewPxH = if (showHeight) {
heightInput.toFloatOrNull()?.let {
ElementDimensions.metersToPixels(it, activePpm)
} ?: pixelSize.height
} else previewPxW
Column(
modifier = modifier
.fillMaxWidth()
.background(Color(0xE6FFFFFF))
.padding(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically
) {
EditActionButton("←") { onNudge(Offset(-10f, 0f)) }
EditActionButton("→") { onNudge(Offset(10f, 0f)) }
EditActionButton("↑") { onNudge(Offset(0f, -10f)) }
EditActionButton("↓") { onNudge(Offset(0f, 10f)) }
EditActionButton("↺") { onRotateBy(-15f * PI.toFloat() / 180f) }
EditActionButton("↻") { onRotateBy(15f * PI.toFloat() / 180f) }
NumericField(
value = rotationInput,
onValueChange = { if (it.isEmpty() || it.matches(SIGNED_DECIMAL_INPUT)) rotationInput = it },
suffix = "°",
modifier = Modifier.width(72.dp),
onDone = { applyRotation() }
)
EditActionButton("+") { onScaleBy(1.15f) }
EditActionButton("-") { onScaleBy(0.85f) }
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text("比例尺", style = MaterialTheme.typography.labelMedium)
NumericField(
value = scaleInput,
onValueChange = { if (it.isEmpty() || it.matches(DECIMAL_INPUT)) scaleInput = it },
suffix = "px/m",
modifier = Modifier.width(88.dp),
onDone = { applyScale() }
)
ApplyButton("应用", onClick = { applyScale() })
Text(
"→ ${previewPxW.roundToInt()}×${previewPxH.roundToInt()} px",
style = MaterialTheme.typography.labelSmall,
color = Color(0xFF1565C0),
modifier = Modifier.weight(1f),
textAlign = TextAlign.End
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
NumericField(
value = widthInput,
onValueChange = { if (it.isEmpty() || it.matches(DECIMAL_INPUT)) widthInput = it },
label = "${widthLabel}(m)",
modifier = Modifier.weight(1f),
onDone = { applyDimensions() }
)
if (showHeight) {
NumericField(
value = heightInput,
onValueChange = { if (it.isEmpty() || it.matches(DECIMAL_INPUT)) heightInput = it },
label = "宽(m)",
modifier = Modifier.weight(1f),
onDone = { applyDimensions() }
)
}
ApplyButton("应用", onClick = { applyDimensions() })
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text("线宽:", modifier = Modifier.padding(end = 8.dp))
Slider(
value = strokeWidth,
onValueChange = onStrokeWidth,
valueRange = 1f..10f,
modifier = Modifier.weight(1f)
)
Text("${strokeWidth.toInt()}px")
}
}
}
@Composable
private fun ApplyButton(label: String, onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.height(40.dp),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp)
) {
Text(label, style = MaterialTheme.typography.labelMedium)
}
}
@Composable
private fun NumericField(
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
label: String? = null,
suffix: String? = null,
onDone: () -> Unit = {}
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.height(52.dp),
label = label?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
suffix = suffix?.let { { Text(it, style = MaterialTheme.typography.labelSmall) } },
singleLine = true,
textStyle = MaterialTheme.typography.bodySmall,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Decimal,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { onDone() })
)
}
private fun formatDecimal(value: Float): String {
return if (value == value.roundToInt().toFloat()) {
value.roundToInt().toString()
} else {
String.format("%.2f", value)
}
}
@Composable
private fun EditActionButton(label: String, onClick: () -> Unit) {
Surface(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.clickable(onClick = onClick),
color = Color(0xFFE3F2FD),
shape = RoundedCornerShape(6.dp)
) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
Text(label, fontSize = 16.sp, color = Color(0xFF1565C0))
}
}
}
@Composable
private fun ToolButton(
text: String,
selected: Boolean,
onClick: () -> Unit
) {
Surface(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick),
color = if (selected) Color(0xFF2196F3) else Color.Transparent,
shape = RoundedCornerShape(8.dp)
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
color = if (selected) Color.White else Color.Black
)
}
}
app\src\main\java\com\accident\scene\diagram\DrawingLogger.kt
bash
package com.accident.scene.diagram
import android.content.Context
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class DrawingLogger(private val context: Context) {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault())
private val logFile: File by lazy {
val timestamp = fileNameFormat.format(Date())
File(context.getExternalFilesDir(null), "drawing_log_$timestamp.txt")
}
fun log(operation: String, details: String) {
val timestamp = dateFormat.format(Date())
val logEntry = "[$timestamp] $operation: $details\n"
try {
logFile.appendText(logEntry)
} catch (e: Exception) {
e.printStackTrace()
}
}
val logFilePath: String
get() = logFile.absolutePath
fun logSaveImage(imagePath: String) {
log("SAVE_IMAGE", "path=$imagePath")
}
fun logAddElement(element: DrawElement) {
val details = when (element) {
is DrawElement.Rectangle ->
"x=${element.position.x}, y=${element.position.y}, width=${element.width}, height=${element.height}"
is DrawElement.Circle ->
"x=${element.position.x}, y=${element.position.y}, radius=${element.radius}"
is DrawElement.Triangle ->
"x=${element.position.x}, y=${element.position.y}, size=${element.size}"
is DrawElement.Line ->
"x1=${element.position.x}, y1=${element.position.y}, x2=${element.endPosition.x}, y2=${element.endPosition.y}"
is DrawElement.DashedLine ->
"x1=${element.position.x}, y1=${element.position.y}, x2=${element.endPosition.x}, y2=${element.endPosition.y}"
is DrawElement.TextElement ->
"x=${element.position.x}, y=${element.position.y}, text=${element.text}"
is DrawElement.IconElement ->
"x=${element.position.x}, y=${element.position.y}, uri=${element.iconUri}"
}
log("ADD_${element.javaClass.simpleName.uppercase()}", details)
}
fun logRotate(elementId: String, angle: Float) {
log("ROTATE", "id=$elementId, angle=$angle")
}
fun logScale(elementId: String, scale: Float) {
log("SCALE", "id=$elementId, scale=$scale")
}
fun logMove(elementId: String, newPosition: androidx.compose.ui.geometry.Offset) {
log("MOVE", "id=$elementId, x=${newPosition.x}, y=${newPosition.y}")
}
fun logDelete(elementId: String) {
log("DELETE", "id=$elementId")
}
}
app\src\main\java\com\accident\scene\diagram\CanvasBitmapRenderer.kt
bash
package com.accident.scene.diagram
import android.graphics.Bitmap
import android.graphics.Paint
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.sqrt
object CanvasBitmapRenderer {
fun render(
elements: List<DrawElement>,
width: Int,
height: Int,
iconBitmaps: Map<String, Bitmap>,
backgroundBitmap: Bitmap?,
density: Density
): Bitmap {
val imageBitmap = ImageBitmap(width, height)
val canvas = Canvas(imageBitmap)
val drawScope = CanvasDrawScope()
drawScope.draw(
density = density,
layoutDirection = LayoutDirection.Ltr,
canvas = canvas,
size = Size(width.toFloat(), height.toFloat())
) {
drawRect(color = Color.White, size = Size(width.toFloat(), height.toFloat()))
backgroundBitmap?.let { with(CanvasDrawUtils) { drawBackgroundFill(it) } }
elements.forEach { element ->
drawElement(element, iconBitmaps[element.id], density.density)
}
}
return imageBitmap.asAndroidBitmap()
}
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawElement(
element: DrawElement,
iconBitmap: Bitmap?,
densityValue: Float
) {
rotate(element.rotation, element.position) {
scale(element.scale, element.position) {
when (element) {
is DrawElement.Rectangle -> drawRect(
color = element.color,
topLeft = element.position,
size = Size(element.width, element.height),
style = Stroke(width = element.strokeWidth)
)
is DrawElement.Circle -> drawCircle(
color = element.color,
center = element.position,
radius = element.radius,
style = Stroke(width = element.strokeWidth)
)
is DrawElement.Triangle -> {
val path = Path().apply {
moveTo(element.position.x, element.position.y - element.size / 2)
lineTo(element.position.x + element.size / 2, element.position.y + element.size / 2)
lineTo(element.position.x - element.size / 2, element.position.y + element.size / 2)
close()
}
drawPath(path, element.color, style = Stroke(width = element.strokeWidth))
}
is DrawElement.Line -> drawLine(
color = element.color,
start = element.position,
end = element.endPosition,
strokeWidth = element.strokeWidth
)
is DrawElement.DashedLine -> drawDashedLine(
element.color,
element.position,
element.endPosition,
element.strokeWidth
)
is DrawElement.TextElement -> {
drawContext.canvas.nativeCanvas.drawText(
element.text,
element.position.x,
element.position.y,
Paint().apply {
color = element.color.toArgb()
textSize = element.fontSize * densityValue
isAntiAlias = true
}
)
}
is DrawElement.IconElement -> {
iconBitmap?.let { bitmap ->
val scaledWidth = element.width
val scaledHeight = element.height
drawContext.canvas.nativeCanvas.drawBitmap(
bitmap,
null,
android.graphics.RectF(
element.position.x,
element.position.y,
element.position.x + scaledWidth,
element.position.y + scaledHeight
),
null
)
}
}
}
}
}
}
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawDashedLine(
color: Color,
start: Offset,
end: Offset,
strokeWidth: Float
) {
val dx = end.x - start.x
val dy = end.y - start.y
val distance = sqrt(dx * dx + dy * dy)
if (distance <= 0f) return
val dashLength = 10f
val gapLength = 5f
var currentDistance = 0f
var drawing = true
while (currentDistance < distance) {
val segmentLength = if (drawing) dashLength else gapLength
val nextDistance = minOf(currentDistance + segmentLength, distance)
val startFraction = currentDistance / distance
val endFraction = nextDistance / distance
if (drawing) {
drawLine(
color = color,
start = Offset(start.x + dx * startFraction, start.y + dy * startFraction),
end = Offset(start.x + dx * endFraction, start.y + dy * endFraction),
strokeWidth = strokeWidth
)
}
currentDistance = nextDistance
drawing = !drawing
}
}
}
app\src\main\java\com\accident\scene\diagram\IconAssets.kt
bash
package com.accident.scene.diagram
import android.content.Context
import android.net.Uri
object IconAssets {
fun listIcons(context: Context): List<Pair<String, Uri>> {
return context.assets.list("icon")
?.filter { it.endsWith(".jpg", ignoreCase = true) || it.endsWith(".png", ignoreCase = true) }
?.sorted()
?.map { fileName ->
val name = fileName.substringBeforeLast('.')
name to Uri.parse("file:///android_asset/icon/$fileName")
}
?: emptyList()
}
}
app\src\main\java\com\accident\scene\diagram\DrawingElements.kt
bash
package com.accident.scene.diagram
import android.net.Uri
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
sealed class DrawElement(
open val id: String,
open val position: Offset,
open val rotation: Float = 0f,
open val scale: Float = 1f
) {
data class Rectangle(
override val id: String,
override val position: Offset,
val width: Float,
val height: Float,
val color: Color = Color.Black,
val strokeWidth: Float = 2f,
override val rotation: Float = 0f,
override val scale: Float = 1f
) : DrawElement(id, position, rotation, scale)
data class Circle(
override val id: String,
override val position: Offset,
val radius: Float,
val color: Color = Color.Black,
val strokeWidth: Float = 2f,
override val rotation: Float = 0f,
override val scale: Float = 1f
) : DrawElement(id, position, rotation, scale)
data class Triangle(
override val id: String,
override val position: Offset,
val size: Float,
val color: Color = Color.Black,
val strokeWidth: Float = 2f,
override val rotation: Float = 0f,
override val scale: Float = 1f
) : DrawElement(id, position, rotation, scale)
data class Line(
override val id: String,
override val position: Offset,
val endPosition: Offset,
val color: Color = Color.Black,
val strokeWidth: Float = 2f,
override val rotation: Float = 0f,
override val scale: Float = 1f
) : DrawElement(id, position, rotation, scale)
data class DashedLine(
override val id: String,
override val position: Offset,
val endPosition: Offset,
val color: Color = Color.Black,
val strokeWidth: Float = 2f,
override val rotation: Float = 0f,
override val scale: Float = 1f
) : DrawElement(id, position, rotation, scale)
data class TextElement(
override val id: String,
override val position: Offset,
val text: String,
val fontSize: Float = 24f,
val color: Color = Color.Black,
override val rotation: Float = 0f,
override val scale: Float = 1f
) : DrawElement(id, position, rotation, scale)
data class IconElement(
override val id: String,
override val position: Offset,
val iconUri: Uri,
val width: Float = 80f,
val height: Float = 80f,
override val rotation: Float = 0f,
override val scale: Float = 1f
) : DrawElement(id, position, rotation, scale)
}
enum class DrawingTool {
SELECT, RECTANGLE, CIRCLE, TRIANGLE, LINE, DASHED_LINE, TEXT, ICON
}
data class DrawingState(
val elements: List<DrawElement> = emptyList(),
val selectedElementId: String? = null,
val currentTool: DrawingTool = DrawingTool.SELECT,
val currentColor: Color = Color.Black,
val currentStrokeWidth: Float = 2f,
val isDrawing: Boolean = false,
val drawStartPosition: Offset? = null,
val backgroundUri: Uri = CanvasDrawUtils.DEFAULT_BACKGROUND_URI,
val pendingIconUri: Uri? = null,
/** 比例尺:每米对应的像素数,默认 0.5px/米 */
val pixelsPerMeter: Float = 0.5f
)
app\src\main\java\com\accident\scene\diagram\DrawingCanvas.kt
bash
package com.accident.scene.diagram
import android.graphics.Bitmap
import android.graphics.Paint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntSize
import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.request.ImageRequest
import coil.request.SuccessResult
import kotlin.math.atan2
import kotlin.math.sqrt
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
@Composable
fun DrawingCanvas(
modifier: Modifier = Modifier,
state: DrawingState,
onDrawStart: (Offset) -> Unit,
onDrawMove: (Offset) -> Unit,
onDrawEnd: (Offset) -> Unit,
onElementSelected: (String?) -> Unit,
onElementMoved: (String, Offset) -> Unit,
onElementRotated: (String, Float) -> Unit,
onElementScaled: (String, Float) -> Unit,
onElementRotatedBy: (String, Float) -> Unit = { _, _ -> },
onElementScaledBy: (String, Float) -> Unit = { _, _ -> },
shiftPressed: Boolean = false,
onPlacePendingIcon: (Offset) -> Unit = {},
onCanvasInteraction: () -> Unit = {},
onCanvasSizeChanged: (IntSize) -> Unit = {}
) {
val density = LocalDensity.current
val context = LocalContext.current
var canvasSize by remember { mutableStateOf(IntSize.Zero) }
var iconBitmaps by remember { mutableStateOf<Map<String, Bitmap>>(emptyMap()) }
var backgroundBitmap by remember { mutableStateOf<Bitmap?>(null) }
var previewEnd by remember { mutableStateOf<Offset?>(null) }
LaunchedEffect(state.backgroundUri) {
try {
val loader = ImageLoader(context)
val request = ImageRequest.Builder(context)
.data(state.backgroundUri)
.allowHardware(false)
.build()
val result = loader.execute(request)
if (result is SuccessResult) {
backgroundBitmap = result.drawable.toBitmap()
}
} catch (e: Exception) {
e.printStackTrace()
}
}
LaunchedEffect(state.elements) {
val iconElements = state.elements.filterIsInstance<DrawElement.IconElement>()
val newBitmaps = mutableMapOf<String, Bitmap>()
for (element in iconElements) {
if (!iconBitmaps.containsKey(element.id)) {
try {
val loader = ImageLoader(context)
val request = ImageRequest.Builder(context)
.data(element.iconUri)
.allowHardware(false)
.build()
val result = loader.execute(request)
if (result is SuccessResult) {
newBitmaps[element.id] = result.drawable.toBitmap()
}
} catch (e: Exception) {
e.printStackTrace()
}
} else {
newBitmaps[element.id] = iconBitmaps[element.id]!!
}
}
iconBitmaps = newBitmaps
}
Canvas(
modifier = modifier
.fillMaxSize()
.background(Color(0xFFF5F5F5))
// 鼠标滚轮缩放(Final 阶段,不干扰触摸手势)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Final)
val hasMouseScroll = event.changes.any {
it.type == PointerType.Mouse && it.scrollDelta != Offset.Zero
}
if (!hasMouseScroll) continue
val selectedId = state.selectedElementId ?: continue
val scrollDelta = event.changes.fold(Offset.Zero) { acc, change ->
acc + change.scrollDelta
}
if (scrollDelta.y != 0f) {
onCanvasInteraction()
val factor = if (scrollDelta.y > 0) 0.92f else 1.08f
onElementScaledBy(selectedId, factor)
}
}
}
}
// 图例放置:选择图例后点击画布放置
.pointerInput(state.pendingIconUri) {
if (state.pendingIconUri != null) {
detectTapGestures { offset ->
onCanvasInteraction()
onPlacePendingIcon(offset)
}
}
}
// 选择模式:单指拖拽/点击 + 双指旋转缩放(并行,互不取消)
.pointerInput(state.currentTool, shiftPressed) {
if (state.currentTool == DrawingTool.SELECT) {
val elements = state.elements
coroutineScope {
launch {
detectTransformGestures(
panZoomLock = false
) { _, _, zoom, rotation ->
val selectedId = state.selectedElementId ?: return@detectTransformGestures
onCanvasInteraction()
// 单指移动由 detectDragGestures 处理,此处仅处理双指旋转/缩放
if (rotation != 0f) {
onElementRotatedBy(selectedId, rotation)
}
if (zoom != 1f) {
onElementScaledBy(selectedId, zoom)
}
}
}
launch {
var dragTargetId: String? = null
var rotateDragStartAngle = 0f
var rotateElementBaseRotation = 0f
var rotateCenter = Offset.Zero
detectDragGestures(
onDragStart = { offset ->
onCanvasInteraction()
val tappedElement = findElementAtPosition(elements, offset)
dragTargetId = tappedElement?.id
onElementSelected(tappedElement?.id)
if (tappedElement != null) {
rotateCenter = CanvasDrawUtils.getElementCenter(tappedElement)
rotateDragStartAngle = atan2(
offset.y - rotateCenter.y,
offset.x - rotateCenter.x
)
rotateElementBaseRotation = tappedElement.rotation
}
},
onDrag = { change, dragAmount ->
change.consume()
val targetId = dragTargetId ?: return@detectDragGestures
onCanvasInteraction()
if (shiftPressed) {
val currentAngle = atan2(
change.position.y - rotateCenter.y,
change.position.x - rotateCenter.x
)
onElementRotated(
targetId,
rotateElementBaseRotation + (currentAngle - rotateDragStartAngle)
)
} else {
onElementMoved(targetId, dragAmount)
}
},
onDragEnd = { dragTargetId = null },
onDragCancel = { dragTargetId = null }
)
}
launch {
detectTapGestures { offset ->
onCanvasInteraction()
val tappedElement = findElementAtPosition(elements, offset)
onElementSelected(tappedElement?.id)
}
}
}
}
}
// 绘图模式:单指拖拽绘制
.pointerInput(state.currentTool) {
if (state.currentTool != DrawingTool.SELECT &&
state.currentTool != DrawingTool.TEXT &&
state.currentTool != DrawingTool.ICON
) {
detectDragGestures(
onDragStart = { offset ->
onCanvasInteraction()
previewEnd = offset
onDrawStart(offset)
},
onDrag = { change, _ ->
change.consume()
previewEnd = change.position
onDrawMove(change.position)
},
onDragEnd = {
previewEnd?.let { onDrawEnd(it) }
previewEnd = null
},
onDragCancel = { previewEnd = null }
)
}
}
) {
canvasSize = IntSize(size.width.toInt(), size.height.toInt())
onCanvasSizeChanged(canvasSize)
backgroundBitmap?.let { with(CanvasDrawUtils) { drawBackgroundFill(it) } }
state.elements.forEach { element ->
val isSelected = element.id == state.selectedElementId
drawElement(element, isSelected, iconBitmaps[element.id], density.density)
}
if (state.isDrawing && state.drawStartPosition != null && previewEnd != null) {
drawPreview(
tool = state.currentTool,
start = state.drawStartPosition!!,
end = previewEnd!!,
color = state.currentColor,
strokeWidth = state.currentStrokeWidth
)
}
}
}
private fun DrawScope.drawPreview(
tool: DrawingTool,
start: Offset,
end: Offset,
color: Color,
strokeWidth: Float
) {
when (tool) {
DrawingTool.RECTANGLE -> {
val width = kotlin.math.abs(end.x - start.x)
val height = kotlin.math.abs(end.y - start.y)
val x = minOf(start.x, end.x)
val y = minOf(start.y, end.y)
drawRect(color, Offset(x, y), Size(width, height), style = Stroke(width = strokeWidth))
}
DrawingTool.CIRCLE -> {
val radius = sqrt((end.x - start.x) * (end.x - start.x) + (end.y - start.y) * (end.y - start.y))
drawCircle(color = color, center = start, radius = radius, style = Stroke(width = strokeWidth))
}
DrawingTool.TRIANGLE -> {
val size = sqrt((end.x - start.x) * (end.x - start.x) + (end.y - start.y) * (end.y - start.y))
drawTriangle(start, size, color, strokeWidth)
}
DrawingTool.LINE -> drawLine(color, start, end, strokeWidth = strokeWidth)
DrawingTool.DASHED_LINE -> drawDashedLine(color, start, end, strokeWidth)
else -> Unit
}
}
private fun findElementAtPosition(elements: List<DrawElement>, position: Offset): DrawElement? {
return elements.lastOrNull { element ->
getElementBounds(element).contains(position)
}
}
private fun getElementBounds(element: DrawElement): Rect {
return when (element) {
is DrawElement.Rectangle -> Rect(
element.position.x,
element.position.y,
element.position.x + element.width * element.scale,
element.position.y + element.height * element.scale
)
is DrawElement.Circle -> {
val r = element.radius * element.scale
Rect(
element.position.x - r,
element.position.y - r,
element.position.x + r,
element.position.y + r
)
}
is DrawElement.Triangle -> {
val size = element.size * element.scale
Rect(
element.position.x - size / 2,
element.position.y - size / 2,
element.position.x + size / 2,
element.position.y + size / 2
)
}
is DrawElement.Line, is DrawElement.DashedLine -> {
val start = element.position
val end = when (element) {
is DrawElement.Line -> element.endPosition
is DrawElement.DashedLine -> element.endPosition
else -> start
}
Rect(
minOf(start.x, end.x) - 20f,
minOf(start.y, end.y) - 20f,
maxOf(start.x, end.x) + 20f,
maxOf(start.y, end.y) + 20f
)
}
is DrawElement.TextElement -> Rect(
element.position.x,
element.position.y - element.fontSize * element.scale,
element.position.x + element.text.length * element.fontSize * 0.6f * element.scale,
element.position.y + element.fontSize * element.scale
)
is DrawElement.IconElement -> Rect(
element.position.x,
element.position.y,
element.position.x + element.width * element.scale,
element.position.y + element.height * element.scale
)
}
}
private fun DrawScope.drawElement(
element: DrawElement,
isSelected: Boolean,
iconBitmap: Bitmap?,
densityValue: Float
) {
val selectionColor = Color(0xFFFF9800)
rotate(element.rotation, element.position) {
scale(element.scale, element.position) {
when (element) {
is DrawElement.Rectangle -> drawRect(
color = element.color,
topLeft = element.position,
size = Size(element.width, element.height),
style = Stroke(width = element.strokeWidth)
)
is DrawElement.Circle -> drawCircle(
color = element.color,
center = element.position,
radius = element.radius,
style = Stroke(width = element.strokeWidth)
)
is DrawElement.Triangle -> drawTriangle(
position = element.position,
size = element.size,
color = element.color,
strokeWidth = element.strokeWidth
)
is DrawElement.Line -> drawLine(
color = element.color,
start = element.position,
end = element.endPosition,
strokeWidth = element.strokeWidth
)
is DrawElement.DashedLine -> drawDashedLine(
color = element.color,
start = element.position,
end = element.endPosition,
strokeWidth = element.strokeWidth
)
is DrawElement.TextElement -> {
drawContext.canvas.nativeCanvas.drawText(
element.text,
element.position.x,
element.position.y,
Paint().apply {
color = element.color.toArgb()
textSize = element.fontSize * densityValue
isAntiAlias = true
}
)
}
is DrawElement.IconElement -> {
iconBitmap?.let { bitmap ->
drawContext.canvas.nativeCanvas.drawBitmap(
bitmap,
null,
android.graphics.RectF(
element.position.x,
element.position.y,
element.position.x + element.width,
element.position.y + element.height
),
null
)
}
}
}
}
}
if (isSelected) {
val bounds = getElementBounds(element)
drawRect(
color = selectionColor,
topLeft = Offset(bounds.left - 4, bounds.top - 4),
size = Size(bounds.width + 8, bounds.height + 8),
style = Stroke(width = 2f)
)
}
}
private fun DrawScope.drawTriangle(
position: Offset,
size: Float,
color: Color,
strokeWidth: Float
) {
val path = Path().apply {
moveTo(position.x, position.y - size / 2)
lineTo(position.x + size / 2, position.y + size / 2)
lineTo(position.x - size / 2, position.y + size / 2)
close()
}
drawPath(path = path, color = color, style = Stroke(width = strokeWidth))
}
private fun DrawScope.drawDashedLine(
color: Color,
start: Offset,
end: Offset,
strokeWidth: Float
) {
val dx = end.x - start.x
val dy = end.y - start.y
val distance = sqrt(dx * dx + dy * dy)
if (distance <= 0f) return
val dashLength = 10f
val gapLength = 5f
var currentDistance = 0f
var drawing = true
while (currentDistance < distance) {
val segmentLength = if (drawing) dashLength else gapLength
val nextDistance = minOf(currentDistance + segmentLength, distance)
val startFraction = currentDistance / distance
val endFraction = nextDistance / distance
if (drawing) {
drawLine(
color = color,
start = Offset(start.x + dx * startFraction, start.y + dy * startFraction),
end = Offset(start.x + dx * endFraction, start.y + dy * endFraction),
strokeWidth = strokeWidth
)
}
currentDistance = nextDistance
drawing = !drawing
}
}
app\src\main\java\com\accident\scene\diagram\CanvasDrawUtils.kt
bash
package com.accident.scene.diagram
import android.graphics.Bitmap
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
object CanvasDrawUtils {
const val DEFAULT_BACKGROUND_ASSET = "icon/A型图.jpg"
val DEFAULT_BACKGROUND_URI get() = android.net.Uri.parse("file:///android_asset/$DEFAULT_BACKGROUND_ASSET")
fun DrawScope.drawBackgroundFill(bitmap: Bitmap) {
drawContext.canvas.nativeCanvas.drawBitmap(
bitmap,
null,
android.graphics.RectF(0f, 0f, size.width, size.height),
null
)
}
fun DrawScope.drawBackgroundFit(bitmap: Bitmap) {
val bw = bitmap.width.toFloat()
val bh = bitmap.height.toFloat()
if (bw <= 0f || bh <= 0f) return
val scale = minOf(size.width / bw, size.height / bh)
val w = bw * scale
val h = bh * scale
val left = (size.width - w) / 2f
val top = (size.height - h) / 2f
drawContext.canvas.nativeCanvas.drawBitmap(
bitmap,
null,
android.graphics.RectF(left, top, left + w, top + h),
null
)
}
fun getElementCenter(element: DrawElement): Offset {
return when (element) {
is DrawElement.Rectangle -> Offset(
element.position.x + element.width * element.scale / 2f,
element.position.y + element.height * element.scale / 2f
)
is DrawElement.Circle -> element.position
is DrawElement.Triangle -> element.position
is DrawElement.Line, is DrawElement.DashedLine -> {
val end = when (element) {
is DrawElement.Line -> element.endPosition
is DrawElement.DashedLine -> element.endPosition
else -> element.position
}
Offset(
(element.position.x + end.x) / 2f,
(element.position.y + end.y) / 2f
)
}
is DrawElement.TextElement -> Offset(
element.position.x + element.text.length * element.fontSize * 0.3f * element.scale,
element.position.y - element.fontSize * element.scale / 2f
)
is DrawElement.IconElement -> Offset(
element.position.x + element.width * element.scale / 2f,
element.position.y + element.height * element.scale / 2f
)
}
}
}
val radius = sqrt((end.x - start.x) * (end.x - start.x) + (end.y - start.y) * (end.y - start.y))
drawCircle(color, start, radius, style = Stroke(width = strokeWidth))
drawCircle(color = color, center = start, radius = radius, style = Stroke(width = strokeWidth))
}
已完成 事故现场绘图 Android 应用,并成功构建、安装到模拟器。
关于 Gradle 9.2.0
可以用。 已验证该路径可用:
D:\ide\MavenRepository\wrapper\dists\gradle-9.2.0-bin\11i5gvueggl8a5cioxuftxrik\gradle-9.2.0\bin\gradle.bat
Everything MCP 还找到了本机其他 Gradle 版本(7.2、8.4、8.11.1、9.2.0)。项目已将 wrapper 升级到 Gradle 9.2.0(AGP 8.7 需要 Gradle ≥ 8.9)。
已实现功能
功能 说明
基础图形
矩形、圆、三角形、直线、虚线(拖拽绘制)
文字
对话框输入后添加到画布
图标
从 icon/ 目录自动加载 30 个 图例到 assets
选择/移动
点击选中,拖拽移动
旋转/缩放
双指手势操作选中元素
绘制日志
写入 Android/data/.../files/drawing_log_*.txt
保存图片
PNG 保存到 Pictures/AccidentSceneDiagram/
构建产物
Debug APK(已安装到模拟器 emulator-5554)
app\build\outputs\apk\debug\app-debug.apk(约 10.7 MB)
Release 签名 APK
app\build\outputs\apk\release\app-release.apk(约 7.5 MB)
签名配置:app/release-keystore.jks(alias: key0,密码: android)
使用说明
绘制图形:选择工具后在画布上拖拽
选择/编辑:选「选择」→ 点击元素 → 拖拽移动 / 双指旋转缩放
添加图标:选「图标」→ 从 30 个图例中选择
保存:点右上角「保存」
构建命令
cd d:\code\kotlin\AccidentSceneDiagram
$env:GRADLE_USER_HOME = "d:\code\kotlin\AccidentSceneDiagram.gradle_home"
.\gradlew.bat assembleRelease # 发行版
.\gradlew.bat assembleDebug # 调试版
也可直接使用你本地的 Gradle 9.2.0:
& "D:\ide\MavenRepository\wrapper\dists\gradle-9.2.0-bin\11i5gvueggl8a5cioxuftxrik\gradle-9.2.0\bin\gradle.bat" assembleRelease
应用已在模拟器上启动。