# 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 属性名一致,无冲突