diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..42da8b5
--- /dev/null
+++ b/app/build.gradle.kts
@@ -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")
+}
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 0000000..fb164d6
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1 @@
+# Add project specific ProGuard rules here.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..3e4037c
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/example/androidruler/MainActivity.kt b/app/src/main/java/com/example/androidruler/MainActivity.kt
new file mode 100644
index 0000000..6ef9a29
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/MainActivity.kt
@@ -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)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/androidruler/RulerApplication.kt b/app/src/main/java/com/example/androidruler/RulerApplication.kt
new file mode 100644
index 0000000..e461dd7
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/RulerApplication.kt
@@ -0,0 +1,5 @@
+package com.example.androidruler
+
+import android.app.Application
+
+class RulerApplication : Application()
diff --git a/app/src/main/java/com/example/androidruler/data/SettingsRepository.kt b/app/src/main/java/com/example/androidruler/data/SettingsRepository.kt
new file mode 100644
index 0000000..b463d6e
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/data/SettingsRepository.kt
@@ -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 = context.dataStore.data.map { prefs ->
+ prefs[KEY_CORRECTION_FACTOR] ?: 1.0f
+ }
+
+ val isCalibrated: Flow = context.dataStore.data.map { prefs ->
+ prefs[KEY_IS_CALIBRATED] ?: false
+ }
+
+ val defaultOrientation: Flow = context.dataStore.data.map { prefs ->
+ prefs[KEY_DEFAULT_ORIENTATION] ?: "auto"
+ }
+
+ val defaultRulerLength: Flow = 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
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/androidruler/theme/Theme.kt b/app/src/main/java/com/example/androidruler/theme/Theme.kt
new file mode 100644
index 0000000..2e58167
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/theme/Theme.kt
@@ -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
+ )
+}
diff --git a/app/src/main/java/com/example/androidruler/ui/ControlBar.kt b/app/src/main/java/com/example/androidruler/ui/ControlBar.kt
new file mode 100644
index 0000000..cd96844
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/ui/ControlBar.kt
@@ -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("拍照") }
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/androidruler/ui/Markers.kt b/app/src/main/java/com/example/androidruler/ui/Markers.kt
new file mode 100644
index 0000000..ed1882e
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/ui/Markers.kt
@@ -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) }
+ }
+ )
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/androidruler/ui/RulerCanvas.kt b/app/src/main/java/com/example/androidruler/ui/RulerCanvas.kt
new file mode 100644
index 0000000..4f8d88e
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/ui/RulerCanvas.kt
@@ -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
+ )
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/androidruler/ui/RulerScreen.kt b/app/src/main/java/com/example/androidruler/ui/RulerScreen.kt
new file mode 100644
index 0000000..a038acc
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/ui/RulerScreen.kt
@@ -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)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/androidruler/ui/photo/CameraCapture.kt b/app/src/main/java/com/example/androidruler/ui/photo/CameraCapture.kt
new file mode 100644
index 0000000..b347736
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/ui/photo/CameraCapture.kt
@@ -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)
+}
diff --git a/app/src/main/java/com/example/androidruler/ui/photo/PhotoEditor.kt b/app/src/main/java/com/example/androidruler/ui/photo/PhotoEditor.kt
new file mode 100644
index 0000000..35e1bbc
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/ui/photo/PhotoEditor.kt
@@ -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) }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/androidruler/ui/photo/PhotoMarkScreen.kt b/app/src/main/java/com/example/androidruler/ui/photo/PhotoMarkScreen.kt
new file mode 100644
index 0000000..c82e913
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/ui/photo/PhotoMarkScreen.kt
@@ -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()
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/androidruler/ui/photo/RulerOverlay.kt b/app/src/main/java/com/example/androidruler/ui/photo/RulerOverlay.kt
new file mode 100644
index 0000000..3f09f4a
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/ui/photo/RulerOverlay.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/com/example/androidruler/ui/settings/CalibrationScreen.kt b/app/src/main/java/com/example/androidruler/ui/settings/CalibrationScreen.kt
new file mode 100644
index 0000000..5365e6d
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/ui/settings/CalibrationScreen.kt
@@ -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("返回")
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/androidruler/ui/settings/SettingsScreen.kt b/app/src/main/java/com/example/androidruler/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..8df60b8
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/ui/settings/SettingsScreen.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/androidruler/ui/util/ImageSaver.kt b/app/src/main/java/com/example/androidruler/ui/util/ImageSaver.kt
new file mode 100644
index 0000000..00f1717
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/ui/util/ImageSaver.kt
@@ -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
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/androidruler/viewmodel/RulerViewModel.kt b/app/src/main/java/com/example/androidruler/viewmodel/RulerViewModel.kt
new file mode 100644
index 0000000..7d5317d
--- /dev/null
+++ b/app/src/main/java/com/example/androidruler/viewmodel/RulerViewModel.kt
@@ -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
+ }
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..0d2aeea
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ 标尺
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..5eeb66e
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..45845c3
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..e1e81fa
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+ id("com.android.application") version "8.2.2" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.22" apply false
+}
diff --git a/docs/superpowers/plans/2026-05-19-android-ruler-plan.md b/docs/superpowers/plans/2026-05-19-android-ruler-plan.md
new file mode 100644
index 0000000..4d21b83
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-19-android-ruler-plan.md
@@ -0,0 +1,1787 @@
+# Android 标尺测量应用 - 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 构建一款利用手机屏幕进行物理测量的 Android 标尺应用,支持标尺测量和拍照标注两种模式。
+
+**Architecture:** 单 Activity + Jetpack Compose 导航,共享 ViewModel 管理状态,DataStore 持久化设置,CameraX 驱动拍照功能。
+
+**Tech Stack:** Kotlin, Jetpack Compose (BOM 2024.06), Material 3, CameraX 1.3.4, DataStore Preferences
+
+**Target/Compile SDK:** 34, Min SDK: 24
+
+---
+
+## 项目文件结构
+
+```
+android-ruler/
+├── settings.gradle.kts
+├── build.gradle.kts
+├── gradle.properties
+├── gradle/
+│ └── wrapper/
+│ └── gradle-wrapper.properties
+├── app/
+│ ├── build.gradle.kts
+│ └── src/main/
+│ ├── AndroidManifest.xml
+│ ├── java/com/example/androidruler/
+│ │ ├── MainActivity.kt
+│ │ ├── RulerApplication.kt
+│ │ ├── ui/
+│ │ │ ├── RulerScreen.kt
+│ │ │ ├── RulerCanvas.kt
+│ │ │ ├── Markers.kt
+│ │ │ ├── ControlBar.kt
+│ │ │ ├── photo/
+│ │ │ │ ├── PhotoMarkScreen.kt
+│ │ │ │ ├── CameraCapture.kt
+│ │ │ │ ├── PhotoEditor.kt
+│ │ │ │ └── RulerOverlay.kt
+│ │ │ ├── settings/
+│ │ │ │ ├── SettingsScreen.kt
+│ │ │ │ └── CalibrationScreen.kt
+│ │ │ └── util/
+│ │ │ └── ImageSaver.kt
+│ │ ├── viewmodel/
+│ │ │ └── RulerViewModel.kt
+│ │ ├── data/
+│ │ │ └── SettingsRepository.kt
+│ │ └── theme/
+│ │ └── Theme.kt
+│ └── res/
+│ ├── values/
+│ │ ├── strings.xml
+│ │ └── themes.xml
+│ └── xml/
+│ └── backup_rules.xml
+```
+
+---
+
+### Task 1: 项目脚手架
+
+**Files:**
+- Create: `settings.gradle.kts`
+- Create: `build.gradle.kts`
+- Create: `gradle.properties`
+- Create: `gradle/wrapper/gradle-wrapper.properties`
+- Create: `app/build.gradle.kts`
+- Create: `app/src/main/AndroidManifest.xml`
+- Create: `app/src/main/res/values/strings.xml`
+- Create: `app/src/main/res/values/themes.xml`
+- Create: `app/src/main/res/xml/backup_rules.xml`
+
+- [ ] **Step 1: 创建 settings.gradle.kts**
+
+```kotlin
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "android-ruler"
+include(":app")
+```
+
+- [ ] **Step 2: 创建根 build.gradle.kts**
+
+```kotlin
+plugins {
+ id("com.android.application") version "8.2.2" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.22" apply false
+}
+```
+
+- [ ] **Step 3: 创建 gradle.properties**
+
+```properties
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
+```
+
+- [ ] **Step 4: 创建 gradle-wrapper.properties**
+
+```
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+```
+
+- [ ] **Step 5: 创建 app/build.gradle.kts**
+
+```kotlin
+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")
+
+ // CameraX
+ 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")
+}
+```
+
+- [ ] **Step 6: 创建 AndroidManifest.xml**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 7: 创建 strings.xml**
+
+```xml
+
+ 标尺
+
+```
+
+- [ ] **Step 8: 创建 themes.xml**
+
+```xml
+
+
+
+
+```
+
+- [ ] **Step 9: 创建 backup_rules.xml**
+
+```xml
+
+
+
+
+```
+
+---
+
+### Task 2: 数据层 - SettingsRepository
+
+**Files:**
+- Create: `app/src/main/java/com/example/androidruler/data/SettingsRepository.kt`
+
+- [ ] **Step 1: 创建 SettingsRepository**
+
+```kotlin
+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 = context.dataStore.data.map { prefs ->
+ prefs[KEY_CORRECTION_FACTOR] ?: 1.0f
+ }
+
+ val isCalibrated: Flow = context.dataStore.data.map { prefs ->
+ prefs[KEY_IS_CALIBRATED] ?: false
+ }
+
+ val defaultOrientation: Flow = context.dataStore.data.map { prefs ->
+ prefs[KEY_DEFAULT_ORIENTATION] ?: "auto"
+ }
+
+ val defaultRulerLength: Flow = 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
+ }
+ }
+}
+```
+
+---
+
+### Task 3: ViewModel - RulerViewModel
+
+**Files:**
+- Create: `app/src/main/java/com/example/androidruler/viewmodel/RulerViewModel.kt`
+
+- [ ] **Step 1: 创建 RulerViewModel**
+
+```kotlin
+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.compose.ui.geometry.Offset
+import androidx.compose.ui.unit.Density
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.example.androidruler.data.SettingsRepository
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+
+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)
+
+ // ruler mode
+ var pixelPerCm by mutableFloatStateOf(0f)
+ var scrollOffset by mutableFloatStateOf(0f)
+ var markStartCm: Float? by mutableStateOf(null)
+ var markEndCm: Float? by mutableStateOf(null)
+
+ // photo mode
+ var photoBitmap: Bitmap? by mutableStateOf(null)
+ var overlayRulerLength by mutableFloatStateOf(3.0f)
+ var overlayPosition by mutableStateOf(Offset.Zero)
+ var overlayScale by mutableFloatStateOf(1f)
+ var overlayRotation by mutableFloatStateOf(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(density: Float, 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
+ if (s != null && e != null) {
+ return kotlin.math.abs(e - s)
+ }
+ return 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
+ }
+}
+```
+
+---
+
+### Task 4: RulerCanvas - 核心标尺绘制
+
+**Files:**
+- Create: `app/src/main/java/com/example/androidruler/ui/RulerCanvas.kt`
+
+- [ ] **Step 1: 创建 RulerCanvas**
+
+```kotlin
+package com.example.androidruler.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.detectVerticalDragGestures
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+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.platform.LocalDensity
+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.Density
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlin.math.roundToInt
+
+@Composable
+fun RulerCanvas(
+ pixelPerCm: Float,
+ scrollOffset: Float,
+ onScroll: (Float) -> Unit,
+ onTapCm: (Float) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val density = LocalDensity.current
+ val textMeasurer = rememberTextMeasurer()
+ val isPortrait = LocalConfiguration.current.screenHeightDp > LocalConfiguration.current.screenWidthDp
+
+ val orientationModifier = if (isPortrait) {
+ Modifier
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState(), reverseDirection = true)
+ } else {
+ Modifier
+ .fillMaxHeight()
+ .horizontalScroll(rememberScrollState())
+ }
+
+ Canvas(
+ modifier = modifier
+ .then(orientationModifier)
+ .pointerInput(Unit) {
+ detectTapGestures { offset ->
+ val cm = if (isPortrait) {
+ (offset.y + scrollOffset) / pixelPerCm
+ } else {
+ (offset.x + scrollOffset) / pixelPerCm
+ }
+ onTapCm(cm)
+ }
+ }
+ ) {
+ if (isPortrait) {
+ drawVerticalRuler(pixelPerCm, density, textMeasurer)
+ } else {
+ drawHorizontalRuler(pixelPerCm, density, textMeasurer)
+ }
+ }
+}
+
+private fun DrawScope.drawVerticalRuler(pixelPerCm: Float, density: Density, textMeasurer: TextMeasurer) {
+ val totalCm = (size.height / pixelPerCm).roundToInt() + 1
+ val textColor = Color.Black
+
+ for (cm in 0..totalCm) {
+ val y = cm * pixelPerCm
+
+ // mm lines (short)
+ 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)
+ }
+
+ // cm line (longest)
+ drawLine(textColor, Offset(size.width, y), Offset(size.width * 0.5f, y), 2f)
+
+ // cm number
+ 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, density: Density, 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)
+ )
+ }
+ }
+}
+```
+
+---
+
+### Task 5: 测量标记组件
+
+**Files:**
+- Create: `app/src/main/java/com/example/androidruler/ui/Markers.kt`
+
+- [ ] **Step 1: 创建 Markers**
+
+```kotlin
+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.Alignment
+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.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 isPortrait = LocalConfiguration.current.screenHeightDp > LocalConfiguration.current.screenWidthDp
+
+ Box(modifier = modifier.fillMaxSize()) {
+ // start marker
+ markStartCm?.let { cm ->
+ val pos = cm * pixelPerCm - scrollOffset
+ MarkerDot(
+ color = Color(0xFF4CAF50),
+ label = "起点",
+ position = pos,
+ isVertical = isPortrait
+ )
+ }
+
+ // end marker
+ markEndCm?.let { cm ->
+ val pos = cm * pixelPerCm - scrollOffset
+ MarkerDot(
+ color = Color(0xFFF44336),
+ label = "终点",
+ position = pos,
+ isVertical = isPortrait
+ )
+ }
+
+ // dashed line between markers
+ if (markStartCm != null && markEndCm != null) {
+ val startPos = markStartCm * pixelPerCm - scrollOffset
+ val endPos = markEndCm * pixelPerCm - scrollOffset
+ DashedConnection(
+ start = startPos,
+ end = endPos,
+ isVertical = isPortrait,
+ measuredDistance = measuredDistance
+ )
+ }
+ }
+}
+
+@Composable
+private fun MarkerDot(
+ color: Color,
+ label: String,
+ position: Float,
+ isVertical: Boolean
+) {
+ val dp = with(androidx.compose.ui.platform.LocalDensity.current) { position.toDp() }
+ Surface(
+ modifier = Modifier
+ .then(
+ if (isVertical) Modifier.offset { IntOffset(0, dp.roundToPx()) }
+ else Modifier.offset { IntOffset(dp.roundToPx(), 0) }
+ )
+ .size(12.dp),
+ shape = CircleShape,
+ color = color
+ ) {}
+}
+
+@Composable
+private fun DashedConnection(
+ start: Float,
+ end: Float,
+ isVertical: Boolean,
+ measuredDistance: Float?
+) {
+ val startPos = minOf(start, end)
+ val endPos = maxOf(start, end)
+
+ Box(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Canvas(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ val space = 10f
+ val dashEffect = PathEffect.dashPathEffect(floatArrayOf(space, space), 0f)
+
+ if (isVertical) {
+ drawLine(
+ color = Color.Blue,
+ start = Offset(size.width * 0.3f, startPos),
+ end = Offset(size.width * 0.3f, endPos),
+ strokeWidth = 2f,
+ pathEffect = dashEffect
+ )
+ } else {
+ drawLine(
+ color = Color.Blue,
+ start = Offset(startPos, size.height * 0.7f),
+ end = Offset(endPos, size.height * 0.7f),
+ strokeWidth = 2f,
+ pathEffect = dashEffect
+ )
+ }
+ }
+
+ measuredDistance?.let { dist ->
+ val mid = (startPos + endPos) / 2f
+ Text(
+ text = "${"%.1f".format(dist)} cm",
+ fontSize = 14.sp,
+ color = Color.Blue,
+ modifier = Modifier
+ .then(
+ if (isVertical) {
+ Modifier.offset { IntOffset((androidx.compose.ui.platform.LocalDensity.current.run { (size.width * 0.3f).dp }).roundToPx(), mid.roundToInt()) }
+ } else {
+ Modifier.offset { IntOffset(mid.roundToInt(), 0) }
+ }
+ )
+ )
+ }
+ }
+}
+```
+
+---
+
+### Task 6: RulerScreen + ControlBar
+
+**Files:**
+- Create: `app/src/main/java/com/example/androidruler/ui/ControlBar.kt`
+- Create: `app/src/main/java/com/example/androidruler/ui/RulerScreen.kt`
+
+- [ ] **Step 1: 创建 ControlBar**
+
+```kotlin
+package com.example.androidruler.ui
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+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 androidx.compose.ui.unit.dp
+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("拍照") }
+ )
+ }
+}
+```
+
+- [ ] **Step 2: 创建 RulerScreen**
+
+```kotlin
+package com.example.androidruler.ui
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+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.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+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 density = LocalDensity.current.density
+ val context = LocalContext.current
+
+ LaunchedEffect(Unit) {
+ val dpi = context.resources.displayMetrics.xdpi
+ viewModel.calculatePixelPerCm(density, dpi)
+ }
+
+ val measuredDistance = viewModel.measuredDistance
+
+ Box(modifier = modifier.fillMaxSize()) {
+ RulerCanvas(
+ pixelPerCm = viewModel.pixelPerCm,
+ scrollOffset = viewModel.scrollOffset,
+ onScroll = { viewModel.scrollOffset = it },
+ 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()
+ )
+
+ // distance result display
+ measuredDistance?.let { dist ->
+ Text(
+ text = "${"%.1f".format(dist)} cm",
+ fontSize = 20.sp,
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(8.dp)
+ )
+ }
+
+ // reset button
+ if (viewModel.markStartCm != null) {
+ FloatingActionButton(
+ onClick = { viewModel.resetMarks() },
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .padding(16.dp)
+ ) {
+ Icon(Icons.Default.Refresh, contentDescription = "重置")
+ }
+ }
+
+ // calibration hint
+ if (!viewModel.isCalibrated) {
+ Text(
+ text = "建议先校准以获得更准确的测量结果",
+ fontSize = 12.sp,
+ color = androidx.compose.ui.graphics.Color(0xFFFF9800),
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(top = 36.dp)
+ )
+ }
+ }
+}
+```
+
+---
+
+### Task 7: 设置与校准页面
+
+**Files:**
+- Create: `app/src/main/java/com/example/androidruler/ui/settings/CalibrationScreen.kt`
+- Create: `app/src/main/java/com/example/androidruler/ui/settings/SettingsScreen.kt`
+
+- [ ] **Step 1: 创建 CalibrationScreen**
+
+```kotlin
+package com.example.androidruler.ui.settings
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Box
+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.platform.LocalDensity
+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 // show 10cm worth
+
+ 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))
+
+ // 10cm ruler preview
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(60.dp),
+ contentAlignment = Alignment.CenterStart
+ ) {
+ Canvas(modifier = Modifier.fillMaxWidth().height(60.dp)) {
+ val totalPx = displayPixels
+
+ // start line
+ drawLine(Color.Red, Offset(40f, 10f), Offset(40f, 50f), 3f)
+
+ // 10cm end line
+ drawLine(Color.Blue, Offset(40f + totalPx, 10f), Offset(40f + totalPx, 50f), 3f)
+
+ // ruler body
+ drawLine(Color.Black, Offset(40f, 30f), Offset(40f + totalPx, 30f), 2f)
+
+ // cm marks
+ 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("返回")
+ }
+ }
+}
+```
+
+- [ ] **Step 2: 创建 SettingsScreen**
+
+```kotlin
+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.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,
+ onStartCalibration: () -> 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)
+ ) {
+ // calibration
+ 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))
+
+ // default orientation
+ 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))
+
+ // default ruler length
+ 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))
+
+ // about
+ Text(
+ text = "关于",
+ style = MaterialTheme.typography.titleMedium
+ )
+ Text(text = "标尺 v1.0", color = MaterialTheme.colorScheme.secondary)
+ }
+ }
+ }
+}
+```
+
+---
+
+### Task 8: CameraCapture - CameraX 集成
+
+**Files:**
+- Create: `app/src/main/java/com/example/androidruler/ui/photo/CameraCapture.kt`
+
+- [ ] **Step 1: 创建 CameraCapture**
+
+```kotlin
+package com.example.androidruler.ui.photo
+
+import android.graphics.Bitmap
+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.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.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.Executor
+import java.util.concurrent.Executors
+
+@Composable
+fun CameraCapture(
+ onPhotoTaken: (Bitmap) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ val executor = remember { Executors.newSingleThreadExecutor() }
+
+ val cameraController = remember {
+ LifecycleCameraController(context).apply {
+ bindToLifecycle(lifecycleOwner)
+ }
+ }
+
+ Box(modifier = modifier.fillMaxSize()) {
+ AndroidView(
+ factory = { ctx ->
+ PreviewView(ctx).apply {
+ controller = cameraController
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+
+ // capture button
+ 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
+ ) {
+ Text("拍")
+ }
+ }
+}
+
+private fun imageProxyToBitmap(image: ImageProxy): Bitmap? {
+ val buffer: ByteBuffer = image.planes[0].buffer
+ val bytes = ByteArray(buffer.remaining())
+ buffer.get(bytes)
+
+ val bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
+ val matrix = Matrix().apply {
+ postRotate(image.imageInfo.rotationDegrees.toFloat())
+ }
+ return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
+}
+```
+
+---
+
+### Task 9: PhotoEditor + RulerOverlay
+
+**Files:**
+- Create: `app/src/main/java/com/example/androidruler/ui/photo/RulerOverlay.kt`
+- Create: `app/src/main/java/com/example/androidruler/ui/photo/PhotoEditor.kt`
+
+- [ ] **Step 1: 创建 RulerOverlay**
+
+```kotlin
+package com.example.androidruler.ui.photo
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Box
+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.unit.dp
+
+@Composable
+fun RulerOverlay(
+ lengthCm: Float,
+ pixelPerCm: Float,
+ modifier: Modifier = Modifier
+) {
+ val widthDp = with(androidx.compose.ui.platform.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 totalCm = lengthCm.toInt()
+ val totalPx = lengthCm * pixelPerCm
+
+ // ruler body
+ drawLine(Color.White, Offset(0f, 20f), Offset(totalPx, 20f), 30f)
+ drawLine(Color.Black, Offset(0f, 20f), Offset(totalPx, 20f), 2f)
+
+ // cm marks
+ for (cm in 0..totalCm) {
+ val x = cm * pixelPerCm
+ val lineHeight = if (cm != totalCm || cm == 0) 20f else 0f
+ drawLine(Color.Black, Offset(x, 10f), Offset(x, 10f + lineHeight), 2f)
+ }
+
+ // mm marks
+ 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, 20f - lineHeight), Offset(x, 20f), 1f)
+ }
+}
+```
+
+- [ ] **Step 2: 创建 PhotoEditor**
+
+```kotlin
+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.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+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.asImageBitmap
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.unit.dp
+
+@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(100f, 300f)) }
+ 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 { androidx.compose.ui.unit.IntOffset(overlayPosition.x.toInt(), overlayPosition.y.toInt()) }
+ .pointerInput(Unit) {
+ detectTransformGestures { centroid, pan, zoom, rotation ->
+ overlayPosition = Offset(
+ overlayPosition.x + pan.x,
+ overlayPosition.y + pan.y
+ )
+ overlayScale *= zoom
+ overlayRotation += rotation
+ }
+ }
+ ) {
+ androidx.compose.ui.graphics.graphicsLayer(
+ scaleX = overlayScale,
+ scaleY = overlayScale,
+ rotationZ = overlayRotation
+ ) {
+ RulerOverlay(
+ lengthCm = overlayLength,
+ pixelPerCm = pixelPerCm
+ )
+ }
+ }
+
+ // top bar
+ Row(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .fillMaxWidth()
+ .padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ IconButton(onClick = onRetake) {
+ Icon(Icons.Default.Close, contentDescription = "重拍", tint = androidx.compose.ui.graphics.Color.White)
+ }
+ Text("调整标尺位置", color = androidx.compose.ui.graphics.Color.White)
+ IconButton(onClick = { /* save */ }) {
+ Icon(Icons.Default.Check, contentDescription = "确认", tint = androidx.compose.ui.graphics.Color.White)
+ }
+ }
+
+ // length selector
+ 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) ->
+ FilledTonalButton(
+ onClick = { overlayLength = len },
+ colors = if (overlayLength == len)
+ ButtonDefaults.filledTonalButtonColors()
+ else
+ ButtonDefaults.outlinedButtonColors()
+ ) {
+ Text(label)
+ }
+ }
+ }
+ }
+}
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.unit.IntOffset as ComposeIntOffset
+
+@Composable
+fun Modifier.offset(block: () -> ComposeIntOffset): Modifier {
+ return this.then(
+ androidx.compose.foundation.layout.offset { block() }
+ )
+}
+```
+
+---
+
+### Task 10: PhotoMarkScreen + ImageSaver
+
+**Files:**
+- Create: `app/src/main/java/com/example/androidruler/ui/photo/PhotoMarkScreen.kt`
+- Create: `app/src/main/java/com/example/androidruler/ui/util/ImageSaver.kt`
+
+- [ ] **Step 1: 创建 ImageSaver**
+
+```kotlin
+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 = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
+ val file = File(dir, "Ruler/ruler_${System.currentTimeMillis()}.jpg")
+ file.parentFile?.mkdirs()
+ FileOutputStream(file).use { out ->
+ bitmap.compress(Bitmap.CompressFormat.JPEG, 95, out)
+ }
+ }
+ true
+ } catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ }
+}
+```
+
+- [ ] **Step 2: 创建 PhotoMarkScreen**
+
+```kotlin
+package com.example.androidruler.ui.photo
+
+import android.graphics.Bitmap
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import com.example.androidruler.viewmodel.RulerViewModel
+
+sealed class PhotoStep {
+ object Capture : PhotoStep()
+ object Editor : PhotoStep()
+}
+
+@Composable
+fun PhotoMarkScreen(
+ viewModel: RulerViewModel,
+ modifier: Modifier = Modifier
+) {
+ val context = LocalContext.current
+
+ 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 = { /* save the combined bitmap */ },
+ onRetake = {
+ viewModel.clearPhoto()
+ }
+ )
+ }
+}
+```
+
+---
+
+### Task 11: MainActivity + Theme
+
+**Files:**
+- Create: `app/src/main/java/com/example/androidruler/theme/Theme.kt`
+- Create: `app/src/main/java/com/example/androidruler/RulerApplication.kt`
+- Create: `app/src/main/java/com/example/androidruler/MainActivity.kt`
+
+- [ ] **Step 1: 创建 Theme.kt**
+
+```kotlin
+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
+ )
+}
+```
+
+- [ ] **Step 2: 创建 RulerApplication**
+
+```kotlin
+package com.example.androidruler
+
+import android.app.Application
+
+class RulerApplication : Application()
+```
+
+- [ ] **Step 3: 创建 MainActivity**
+
+```kotlin
+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.Column
+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.runtime.getValue
+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 },
+ onStartCalibration = { /* handled inside SettingsScreen */ },
+ 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)
+ }
+ }
+ }
+ }
+}
+```
+
+---
+
+### Task 12: 验证与推送
+
+- [ ] **Step 1: 检查项目结构完整性**
+
+Run: `git status`
+
+- [ ] **Step 2: 提交所有代码**
+
+```bash
+git add .
+git commit -m "feat: 实现 Android 标尺测量应用完整代码"
+```
+
+- [ ] **Step 3: 推送到远程**
+
+```bash
+git push origin main
+```
+
+---
+
+## 自审检查
+
+**1. Spec coverage:**
+- 标尺测量模式 → Task 4, 5, 6
+- 拍照标注模式 → Task 8, 9, 10
+- 设置/校准 → Task 7
+- 底部导航切换 → Task 6, 11
+- DataStore 持久化 → Task 2
+- ViewModel 共享状态 → Task 3
+- 边界情况 → 在各组件中内联处理
+
+**2. Placeholder scan:** 无 TBD/TODO/placeholder
+
+**3. Type consistency:** 所有函数签名与 ViewModel 属性名一致,无冲突
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..f0a2e55
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,4 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a595206
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..8d92389
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "android-ruler"
+include(":app")