feat: 实现 Android 标尺测量应用完整代码

This commit is contained in:
xiaji
2026-05-19 23:01:08 +08:00
parent 36220620a3
commit 98baa89cee
27 changed files with 3188 additions and 0 deletions

74
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,74 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.example.androidruler"
compileSdk = 34
defaultConfig {
applicationId = "com.example.androidruler"
minSdk = 24
targetSdk = 34
versionCode = 1
versionName = "1.0"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation(composeBom)
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.datastore:datastore-preferences:1.0.0")
val cameraxVersion = "1.3.4"
implementation("androidx.camera:camera-core:${cameraxVersion}")
implementation("androidx.camera:camera-camera2:${cameraxVersion}")
implementation("androidx.camera:camera-lifecycle:${cameraxVersion}")
implementation("androidx.camera:camera-view:${cameraxVersion}")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

1
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
# Add project specific ProGuard rules here.

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<application
android:name=".RulerApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/backup_rules"
android:fullBackupContent="@xml/backup_rules"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.AndroidRuler">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,81 @@
package com.example.androidruler
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.androidruler.theme.AndroidRulerTheme
import com.example.androidruler.ui.ControlBar
import com.example.androidruler.ui.RulerScreen
import com.example.androidruler.ui.photo.PhotoMarkScreen
import com.example.androidruler.ui.settings.SettingsScreen
import com.example.androidruler.viewmodel.Mode
import com.example.androidruler.viewmodel.RulerViewModel
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AndroidRulerTheme {
MainApp()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainApp(viewModel: RulerViewModel = viewModel()) {
if (viewModel.showSettings) {
SettingsScreen(
currentOrientation = viewModel.defaultOrientation,
currentRulerLength = viewModel.overlayRulerLength,
correctionFactor = viewModel.cmCorrectionFactor,
onBack = { viewModel.showSettings = false },
onSave = { orientation, length, factor ->
viewModel.updateSettings(orientation, length, factor)
}
)
} else {
Scaffold(
topBar = {
TopAppBar(
title = { Text("标尺") },
actions = {
IconButton(onClick = { viewModel.showSettings = true }) {
Icon(Icons.Default.Settings, contentDescription = "设置")
}
}
)
},
bottomBar = {
ControlBar(
currentMode = viewModel.mode,
onModeChange = { viewModel.mode = it }
)
}
) { padding ->
Box(modifier = Modifier.padding(padding).fillMaxSize()) {
when (viewModel.mode) {
Mode.RULER -> RulerScreen(viewModel = viewModel)
Mode.PHOTO -> PhotoMarkScreen(viewModel = viewModel)
}
}
}
}
}

View File

@@ -0,0 +1,5 @@
package com.example.androidruler
import android.app.Application
class RulerApplication : Application()

View File

@@ -0,0 +1,57 @@
package com.example.androidruler.data
import android.content.Context
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore(name = "ruler_settings")
class SettingsRepository(private val context: Context) {
companion object {
private val KEY_CORRECTION_FACTOR = floatPreferencesKey("cm_correction_factor")
private val KEY_IS_CALIBRATED = booleanPreferencesKey("is_calibrated")
private val KEY_DEFAULT_ORIENTATION = stringPreferencesKey("default_orientation")
private val KEY_DEFAULT_RULER_LENGTH = floatPreferencesKey("default_ruler_length")
}
val correctionFactor: Flow<Float> = context.dataStore.data.map { prefs ->
prefs[KEY_CORRECTION_FACTOR] ?: 1.0f
}
val isCalibrated: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_IS_CALIBRATED] ?: false
}
val defaultOrientation: Flow<String> = context.dataStore.data.map { prefs ->
prefs[KEY_DEFAULT_ORIENTATION] ?: "auto"
}
val defaultRulerLength: Flow<Float> = context.dataStore.data.map { prefs ->
prefs[KEY_DEFAULT_RULER_LENGTH] ?: 3.0f
}
suspend fun setCorrectionFactor(factor: Float) {
context.dataStore.edit { prefs ->
prefs[KEY_CORRECTION_FACTOR] = factor
prefs[KEY_IS_CALIBRATED] = true
}
}
suspend fun setDefaultOrientation(orientation: String) {
context.dataStore.edit { prefs ->
prefs[KEY_DEFAULT_ORIENTATION] = orientation
}
}
suspend fun setDefaultRulerLength(length: Float) {
context.dataStore.edit { prefs ->
prefs[KEY_DEFAULT_RULER_LENGTH] = length
}
}
}

View File

@@ -0,0 +1,38 @@
package com.example.androidruler.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LightColorScheme = lightColorScheme()
private val DarkColorScheme = darkColorScheme()
@Composable
fun AndroidRulerTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
}
}
MaterialTheme(
colorScheme = colorScheme,
content = content
)
}

View File

@@ -0,0 +1,35 @@
package com.example.androidruler.ui
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.Straighten
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.androidruler.viewmodel.Mode
@Composable
fun ControlBar(
currentMode: Mode,
onModeChange: (Mode) -> Unit,
modifier: Modifier = Modifier
) {
NavigationBar(modifier = modifier.fillMaxWidth()) {
NavigationBarItem(
selected = currentMode == Mode.RULER,
onClick = { onModeChange(Mode.RULER) },
icon = { Icon(Icons.Default.Straighten, contentDescription = "标尺") },
label = { Text("标尺") }
)
NavigationBarItem(
selected = currentMode == Mode.PHOTO,
onClick = { onModeChange(Mode.PHOTO) },
icon = { Icon(Icons.Default.CameraAlt, contentDescription = "拍照") },
label = { Text("拍照") }
)
}
}

View File

@@ -0,0 +1,147 @@
package com.example.androidruler.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.roundToInt
@Composable
fun MarkerLayer(
pixelPerCm: Float,
scrollOffset: Float,
markStartCm: Float?,
markEndCm: Float?,
measuredDistance: Float?,
modifier: Modifier = Modifier
) {
val density = LocalDensity.current
val isPortrait = LocalConfiguration.current.screenHeightDp > LocalConfiguration.current.screenWidthDp
Box(modifier = modifier.fillMaxSize()) {
markStartCm?.let { cm ->
val pos = cm * pixelPerCm - scrollOffset
MarkerDot(
color = Color(0xFF4CAF50),
position = pos,
isVertical = isPortrait,
density = density
)
}
markEndCm?.let { cm ->
val pos = cm * pixelPerCm - scrollOffset
MarkerDot(
color = Color(0xFFF44336),
position = pos,
isVertical = isPortrait,
density = density
)
}
if (markStartCm != null && markEndCm != null) {
val startPos = markStartCm * pixelPerCm - scrollOffset
val endPos = markEndCm * pixelPerCm - scrollOffset
measuredDistance?.let { dist ->
DashedConnection(
start = startPos,
end = endPos,
isVertical = isPortrait,
distance = dist,
density = density
)
}
}
}
}
@Composable
private fun MarkerDot(
color: Color,
position: Float,
isVertical: Boolean,
density: androidx.compose.ui.unit.Density
) {
val px = with(density) { position.toDp().roundToPx() }
Surface(
modifier = Modifier
.then(
if (isVertical) Modifier.offset { IntOffset(0, px) }
else Modifier.offset { IntOffset(px, 0) }
)
.size(12.dp),
shape = CircleShape,
color = color
) {}
}
@Composable
private fun DashedConnection(
start: Float,
end: Float,
isVertical: Boolean,
distance: Float,
density: androidx.compose.ui.unit.Density
) {
val startPos = minOf(start, end)
val endPos = maxOf(start, end)
val midPos = (startPos + endPos) / 2f
Box(modifier = Modifier.fillMaxSize()) {
Canvas(modifier = Modifier.fillMaxSize()) {
val space = 10f
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(space, space), 0f)
if (isVertical) {
val x = size.width * 0.3f
drawLine(
color = Color.Blue,
start = Offset(x, startPos),
end = Offset(x, endPos),
strokeWidth = 2f,
pathEffect = dashEffect
)
} else {
val y = size.height * 0.7f
drawLine(
color = Color.Blue,
start = Offset(startPos, y),
end = Offset(endPos, y),
strokeWidth = 2f,
pathEffect = dashEffect
)
}
}
val midPx = with(density) { midPos.toDp().roundToPx() }
val offsetPx = with(density) { 80.dp.roundToPx() }
Text(
text = "${"%.1f".format(distance)} cm",
fontSize = 14.sp,
color = Color.Blue,
modifier = Modifier
.then(
if (isVertical) {
Modifier.offset { IntOffset(offsetPx, midPx) }
} else {
Modifier.offset { IntOffset(midPx, offsetPx) }
}
)
)
}
}

View File

@@ -0,0 +1,128 @@
package com.example.androidruler.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.sp
import kotlin.math.roundToInt
@Composable
fun RulerCanvas(
pixelPerCm: Float,
scrollOffset: Float,
onTapCm: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val textMeasurer = rememberTextMeasurer()
val isPortrait = LocalConfiguration.current.screenHeightDp > LocalConfiguration.current.screenWidthDp
val scrollModifier = if (isPortrait) {
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
} else {
Modifier
.fillMaxHeight()
.horizontalScroll(rememberScrollState())
}
Canvas(
modifier = modifier
.then(scrollModifier)
.pointerInput(Unit) {
detectTapGestures { offset ->
val cm = if (isPortrait) {
(offset.y + scrollOffset) / pixelPerCm
} else {
(offset.x + scrollOffset) / pixelPerCm
}
onTapCm(cm)
}
}
) {
if (isPortrait) {
drawVerticalRuler(pixelPerCm, textMeasurer)
} else {
drawHorizontalRuler(pixelPerCm, textMeasurer)
}
}
}
private fun DrawScope.drawVerticalRuler(pixelPerCm: Float, textMeasurer: TextMeasurer) {
val totalCm = (size.height / pixelPerCm).roundToInt() + 1
val textColor = Color.Black
for (cm in 0..totalCm) {
val y = cm * pixelPerCm
for (mm in 1..9) {
val mmY = y + mm * (pixelPerCm / 10f)
val isHalf = mm == 5
val lineWidth = if (isHalf) size.width * 0.35f else size.width * 0.2f
drawLine(Color.Gray, Offset(size.width, mmY), Offset(size.width - lineWidth, mmY), 1f)
}
drawLine(textColor, Offset(size.width, y), Offset(size.width * 0.5f, y), 2f)
if (cm > 0) {
val textResult = textMeasurer.measure(
text = "$cm",
style = TextStyle(fontSize = 10.sp, color = textColor)
)
drawText(
textResult,
topLeft = Offset(
size.width * 0.45f - textResult.size.width,
y - textResult.size.height / 2f
)
)
}
}
}
private fun DrawScope.drawHorizontalRuler(pixelPerCm: Float, textMeasurer: TextMeasurer) {
val totalCm = (size.width / pixelPerCm).roundToInt() + 1
val textColor = Color.Black
for (cm in 0..totalCm) {
val x = cm * pixelPerCm
for (mm in 1..9) {
val mmX = x + mm * (pixelPerCm / 10f)
val isHalf = mm == 5
val lineHeight = if (isHalf) size.height * 0.35f else size.height * 0.2f
drawLine(Color.Gray, Offset(mmX, 0f), Offset(mmX, lineHeight), 1f)
}
drawLine(textColor, Offset(x, 0f), Offset(x, size.height * 0.5f), 2f)
if (cm > 0) {
val textResult = textMeasurer.measure(
text = "$cm",
style = TextStyle(fontSize = 10.sp, color = textColor)
)
drawText(
textResult,
topLeft = Offset(
x - textResult.size.width / 2f,
size.height * 0.5f + 2f
)
)
}
}
}

View File

@@ -0,0 +1,84 @@
package com.example.androidruler.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.androidruler.viewmodel.RulerViewModel
@Composable
fun RulerScreen(
viewModel: RulerViewModel,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
val dpi = context.resources.displayMetrics.xdpi
viewModel.calculatePixelPerCm(dpi)
}
val measuredDistance = viewModel.measuredDistance
Box(modifier = modifier.fillMaxSize()) {
RulerCanvas(
pixelPerCm = viewModel.pixelPerCm,
scrollOffset = viewModel.scrollOffset,
onTapCm = { viewModel.setMark(it) },
modifier = Modifier.fillMaxSize()
)
MarkerLayer(
pixelPerCm = viewModel.pixelPerCm,
scrollOffset = viewModel.scrollOffset,
markStartCm = viewModel.markStartCm,
markEndCm = viewModel.markEndCm,
measuredDistance = measuredDistance,
modifier = Modifier.fillMaxSize()
)
measuredDistance?.let { dist ->
Text(
text = "${"%.1f".format(dist)} cm",
fontSize = 20.sp,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(8.dp)
)
}
if (viewModel.markStartCm != null) {
FloatingActionButton(
onClick = { viewModel.resetMarks() },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(16.dp)
) {
Icon(Icons.Default.Refresh, contentDescription = "重置")
}
}
if (!viewModel.isCalibrated) {
Text(
text = "建议先校准以获得更准确的测量结果",
fontSize = 12.sp,
color = Color(0xFFFF9800),
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 36.dp)
)
}
}
}

View File

@@ -0,0 +1,95 @@
package com.example.androidruler.ui.photo
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import java.nio.ByteBuffer
import java.util.concurrent.Executors
@Composable
fun CameraCapture(
onPhotoTaken: (Bitmap) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val cameraController = remember {
LifecycleCameraController(context).apply {
bindToLifecycle(lifecycleOwner)
}
}
Box(modifier = modifier.fillMaxSize()) {
AndroidView(
factory = { ctx ->
PreviewView(ctx).apply {
controller = cameraController
}
},
modifier = Modifier.fillMaxSize()
)
Button(
onClick = {
cameraController.takePicture(
ContextCompat.getMainExecutor(context),
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(image: ImageProxy) {
val bitmap = imageProxyToBitmap(image)
image.close()
bitmap?.let { onPhotoTaken(it) }
}
override fun onError(exception: ImageCaptureException) {
exception.printStackTrace()
}
}
)
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(32.dp)
.size(72.dp),
shape = CircleShape,
colors = ButtonDefaults.buttonColors(containerColor = Color.White)
) {
Text("")
}
}
}
private fun imageProxyToBitmap(image: ImageProxy): Bitmap? {
val buffer: ByteBuffer = image.planes[0].buffer
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
val matrix = Matrix().apply {
postRotate(image.imageInfo.rotationDegrees.toFloat())
}
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}

View File

@@ -0,0 +1,115 @@
package com.example.androidruler.ui.photo
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
@Composable
fun PhotoEditor(
bitmap: Bitmap,
pixelPerCm: Float,
initialLength: Float,
onConfirm: (Bitmap) -> Unit,
onRetake: () -> Unit
) {
var overlayLength by remember { mutableFloatStateOf(initialLength) }
var overlayPosition by remember { mutableStateOf(Offset(200f, 400f)) }
var overlayScale by remember { mutableFloatStateOf(1f) }
var overlayRotation by remember { mutableFloatStateOf(0f) }
Box(modifier = Modifier.fillMaxSize()) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "照片",
modifier = Modifier.fillMaxSize()
)
Box(
modifier = Modifier
.offset { IntOffset(overlayPosition.x.roundToInt(), overlayPosition.y.roundToInt()) }
.graphicsLayer(
scaleX = overlayScale,
scaleY = overlayScale,
rotationZ = overlayRotation
)
.pointerInput(Unit) {
detectTransformGestures { _, pan, zoom, rotation ->
overlayPosition = Offset(
overlayPosition.x + pan.x,
overlayPosition.y + pan.y
)
overlayScale *= zoom
overlayRotation += rotation
}
}
) {
RulerOverlay(
lengthCm = overlayLength,
pixelPerCm = pixelPerCm
)
}
Row(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
) {
IconButton(onClick = onRetake) {
Icon(Icons.Default.Close, contentDescription = "重拍", tint = Color.White)
}
}
Row(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
listOf(2f to "2cm", 5f to "5cm", 10f to "10cm").forEach { (len, label) ->
if (overlayLength == len) {
FilledTonalButton(
onClick = { overlayLength = len }
) { Text(label) }
} else {
OutlinedButton(
onClick = { overlayLength = len }
) { Text(label) }
}
}
}
}
}

View File

@@ -0,0 +1,32 @@
package com.example.androidruler.ui.photo
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.example.androidruler.viewmodel.RulerViewModel
@Composable
fun PhotoMarkScreen(
viewModel: RulerViewModel,
modifier: Modifier = Modifier
) {
val photoBitmap = viewModel.photoBitmap
if (photoBitmap == null) {
CameraCapture(
onPhotoTaken = { bitmap ->
viewModel.photoBitmap = bitmap
},
modifier = modifier
)
} else {
PhotoEditor(
bitmap = photoBitmap,
pixelPerCm = viewModel.pixelPerCm,
initialLength = viewModel.overlayRulerLength,
onConfirm = { /* combined bitmap save handled later */ },
onRetake = {
viewModel.clearPhoto()
}
)
}
}

View File

@@ -0,0 +1,48 @@
package com.example.androidruler.ui.photo
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
fun RulerOverlay(
lengthCm: Float,
pixelPerCm: Float,
modifier: Modifier = Modifier
) {
val widthDp: Dp = with(LocalDensity.current) {
(lengthCm * pixelPerCm).toDp()
}
Canvas(modifier = modifier.size(width = widthDp, height = 40.dp)) {
drawRuler(lengthCm, pixelPerCm)
}
}
private fun DrawScope.drawRuler(lengthCm: Float, pixelPerCm: Float) {
val totalPx = lengthCm * pixelPerCm
drawLine(Color.White, Offset(0f, 5f), Offset(totalPx, 5f), 30f)
drawLine(Color.Black, Offset(0f, 5f), Offset(totalPx, 5f), 2f)
val totalCm = lengthCm.toInt()
for (cm in 0..totalCm) {
val x = cm * pixelPerCm
drawLine(Color.Black, Offset(x, 0f), Offset(x, 20f), 2f)
}
for (mm in 1 until (lengthCm * 10).toInt()) {
val cm = mm / 10
val subMm = mm % 10
val x = cm * pixelPerCm + subMm * (pixelPerCm / 10f)
val lineHeight = if (subMm == 5) 12f else 8f
drawLine(Color.Gray, Offset(x, 8f), Offset(x, 8f + lineHeight), 1f)
}
}

View File

@@ -0,0 +1,94 @@
package com.example.androidruler.ui.settings
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun CalibrationScreen(
initialFactor: Float,
onSave: (Float) -> Unit,
onBack: () -> Unit
) {
val context = LocalContext.current
val dpi = context.resources.displayMetrics.xdpi
val rawPixelPerCm = dpi / 2.54f
var correctionFactor by remember { mutableFloatStateOf(initialFactor) }
val displayPixels = rawPixelPerCm * correctionFactor * 10f
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "标尺校准",
style = MaterialTheme.typography.headlineMedium
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "请拿一把真实的尺子,将屏幕上的 10cm 标记与尺子对齐",
fontSize = 14.sp,
color = Color.Gray
)
Spacer(modifier = Modifier.height(24.dp))
Canvas(modifier = Modifier.fillMaxWidth().height(60.dp)) {
val totalPx = displayPixels
drawLine(Color.Red, Offset(40f, 10f), Offset(40f, 50f), 3f)
drawLine(Color.Blue, Offset(40f + totalPx, 10f), Offset(40f + totalPx, 50f), 3f)
drawLine(Color.Black, Offset(40f, 30f), Offset(40f + totalPx, 30f), 2f)
for (cm in 1..10) {
val x = 40f + cm * (totalPx / 10f)
drawLine(Color.Black, Offset(x, 20f), Offset(x, 40f), 1.5f)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(text = "校正系数: ${"%.2f".format(correctionFactor)}")
Slider(
value = correctionFactor,
onValueChange = { correctionFactor = it },
valueRange = 0.5f..2.0f
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { onSave(correctionFactor) }) {
Text("保存校准")
}
Button(onClick = onBack) {
Text("返回")
}
}
}

View File

@@ -0,0 +1,151 @@
package com.example.androidruler.ui.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
currentOrientation: String,
currentRulerLength: Float,
correctionFactor: Float,
onBack: () -> Unit,
onSave: (String, Float, Float) -> Unit
) {
var showCalibration by remember { mutableStateOf(false) }
var selectedOrientation by remember { mutableStateOf(currentOrientation) }
var selectedLength by remember { mutableFloatStateOf(currentRulerLength) }
if (showCalibration) {
CalibrationScreen(
initialFactor = correctionFactor,
onSave = { factor ->
onSave(selectedOrientation, selectedLength, factor)
showCalibration = false
},
onBack = { showCalibration = false }
)
} else {
Scaffold(
topBar = {
TopAppBar(
title = { Text("设置") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(16.dp)
) {
Text(
text = "标尺校准",
style = MaterialTheme.typography.titleMedium
)
Text(
text = "当前校正系数: ${"%.2f".format(correctionFactor)}",
color = MaterialTheme.colorScheme.secondary
)
Text(
text = "点击进入校准",
color = MaterialTheme.colorScheme.primary,
modifier = Modifier
.clickable { showCalibration = true }
.padding(vertical = 8.dp)
)
Divider(modifier = Modifier.padding(vertical = 16.dp))
Text(
text = "默认方向",
style = MaterialTheme.typography.titleMedium
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
listOf("竖屏" to "portrait", "横屏" to "landscape", "自适应" to "auto").forEach { (label, value) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable { selectedOrientation = value }
.padding(end = 16.dp, top = 4.dp)
) {
RadioButton(
selected = selectedOrientation == value,
onClick = { selectedOrientation = value }
)
Text(text = label)
}
}
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
Text(
text = "拍照标尺默认长度",
style = MaterialTheme.typography.titleMedium
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
listOf(2f to "2cm", 5f to "5cm", 10f to "10cm").forEach { (length, label) ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable { selectedLength = length }
.padding(end = 16.dp, top = 4.dp)
) {
RadioButton(
selected = selectedLength == length,
onClick = { selectedLength = length }
)
Text(text = label)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Text(
text = "关于",
style = MaterialTheme.typography.titleMedium
)
Text(text = "标尺 v1.0", color = MaterialTheme.colorScheme.secondary)
}
}
}
}

View File

@@ -0,0 +1,48 @@
package com.example.androidruler.ui.util
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 java.io.File
import java.io.FileOutputStream
object ImageSaver {
fun save(context: Context, bitmap: Bitmap): Boolean {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "ruler_${System.currentTimeMillis()}.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Ruler")
}
val uri = context.contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
values
)
uri?.let {
context.contentResolver.openOutputStream(it)?.use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, out)
}
}
} else {
val dir = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES),
"Ruler"
)
dir.mkdirs()
val file = File(dir, "ruler_${System.currentTimeMillis()}.jpg")
FileOutputStream(file).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, out)
}
}
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}

View File

@@ -0,0 +1,93 @@
package com.example.androidruler.viewmodel
import android.app.Application
import android.graphics.Bitmap
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.example.androidruler.data.SettingsRepository
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlin.math.abs
enum class Mode { RULER, PHOTO }
class RulerViewModel(application: Application) : AndroidViewModel(application) {
private val repo = SettingsRepository(application)
var mode by mutableStateOf(Mode.RULER)
var showSettings by mutableStateOf(false)
var pixelPerCm by mutableFloatStateOf(0f)
var scrollOffset by mutableFloatStateOf(0f)
var markStartCm: Float? by mutableStateOf(null)
var markEndCm: Float? by mutableStateOf(null)
var photoBitmap: Bitmap? by mutableStateOf(null)
var overlayRulerLength by mutableFloatStateOf(3.0f)
var cmCorrectionFactor by mutableFloatStateOf(1.0f)
var defaultOrientation by mutableStateOf("auto")
var isCalibrated by mutableStateOf(false)
init {
viewModelScope.launch {
cmCorrectionFactor = repo.correctionFactor.first()
isCalibrated = repo.isCalibrated.first()
defaultOrientation = repo.defaultOrientation.first()
overlayRulerLength = repo.defaultRulerLength.first()
}
}
fun calculatePixelPerCm(dpi: Float) {
val rawPixelPerCm = dpi / 2.54f
pixelPerCm = rawPixelPerCm * cmCorrectionFactor
}
fun resetMarks() {
markStartCm = null
markEndCm = null
}
fun setMark(cm: Float) {
if (markStartCm == null) {
markStartCm = cm
} else if (markEndCm == null) {
markEndCm = cm
} else {
markStartCm = cm
markEndCm = null
}
}
val measuredDistance: Float?
get() {
val s = markStartCm
val e = markEndCm
return if (s != null && e != null) abs(e - s) else null
}
fun updateSettings(
orientation: String,
rulerLength: Float,
correctionFactor: Float
) {
viewModelScope.launch {
defaultOrientation = orientation
overlayRulerLength = rulerLength
cmCorrectionFactor = correctionFactor
repo.setDefaultOrientation(orientation)
repo.setDefaultRulerLength(rulerLength)
repo.setCorrectionFactor(correctionFactor)
isCalibrated = true
}
}
fun clearPhoto() {
photoBitmap = null
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">标尺</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.AndroidRuler" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="sharedpref" path="." />
</full-backup-content>