From 98baa89ceee8a482a8f0c39168ba7f5039baa77d Mon Sep 17 00:00:00 2001 From: xiaji Date: Tue, 19 May 2026 23:01:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Android=20?= =?UTF-8?q?=E6=A0=87=E5=B0=BA=E6=B5=8B=E9=87=8F=E5=BA=94=E7=94=A8=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 74 + app/proguard-rules.pro | 1 + app/src/main/AndroidManifest.xml | 32 + .../com/example/androidruler/MainActivity.kt | 81 + .../example/androidruler/RulerApplication.kt | 5 + .../androidruler/data/SettingsRepository.kt | 57 + .../com/example/androidruler/theme/Theme.kt | 38 + .../com/example/androidruler/ui/ControlBar.kt | 35 + .../com/example/androidruler/ui/Markers.kt | 147 ++ .../example/androidruler/ui/RulerCanvas.kt | 128 ++ .../example/androidruler/ui/RulerScreen.kt | 84 + .../androidruler/ui/photo/CameraCapture.kt | 95 + .../androidruler/ui/photo/PhotoEditor.kt | 115 ++ .../androidruler/ui/photo/PhotoMarkScreen.kt | 32 + .../androidruler/ui/photo/RulerOverlay.kt | 48 + .../ui/settings/CalibrationScreen.kt | 94 + .../ui/settings/SettingsScreen.kt | 151 ++ .../androidruler/ui/util/ImageSaver.kt | 48 + .../androidruler/viewmodel/RulerViewModel.kt | 93 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/values/themes.xml | 4 + app/src/main/res/xml/backup_rules.xml | 4 + build.gradle.kts | 4 + .../plans/2026-05-19-android-ruler-plan.md | 1787 +++++++++++++++++ gradle.properties | 4 + gradle/wrapper/gradle-wrapper.properties | 5 + settings.gradle.kts | 18 + 27 files changed, 3188 insertions(+) create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/example/androidruler/MainActivity.kt create mode 100644 app/src/main/java/com/example/androidruler/RulerApplication.kt create mode 100644 app/src/main/java/com/example/androidruler/data/SettingsRepository.kt create mode 100644 app/src/main/java/com/example/androidruler/theme/Theme.kt create mode 100644 app/src/main/java/com/example/androidruler/ui/ControlBar.kt create mode 100644 app/src/main/java/com/example/androidruler/ui/Markers.kt create mode 100644 app/src/main/java/com/example/androidruler/ui/RulerCanvas.kt create mode 100644 app/src/main/java/com/example/androidruler/ui/RulerScreen.kt create mode 100644 app/src/main/java/com/example/androidruler/ui/photo/CameraCapture.kt create mode 100644 app/src/main/java/com/example/androidruler/ui/photo/PhotoEditor.kt create mode 100644 app/src/main/java/com/example/androidruler/ui/photo/PhotoMarkScreen.kt create mode 100644 app/src/main/java/com/example/androidruler/ui/photo/RulerOverlay.kt create mode 100644 app/src/main/java/com/example/androidruler/ui/settings/CalibrationScreen.kt create mode 100644 app/src/main/java/com/example/androidruler/ui/settings/SettingsScreen.kt create mode 100644 app/src/main/java/com/example/androidruler/ui/util/ImageSaver.kt create mode 100644 app/src/main/java/com/example/androidruler/viewmodel/RulerViewModel.kt create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 build.gradle.kts create mode 100644 docs/superpowers/plans/2026-05-19-android-ruler-plan.md create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 settings.gradle.kts 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 @@ + + +