diff --git a/app/src/main/java/com/onthelevel/core/sensors/RiseRun.kt b/app/src/main/java/com/onthelevel/core/sensors/RiseRun.kt new file mode 100644 index 0000000..07cf1a6 --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/sensors/RiseRun.kt @@ -0,0 +1,15 @@ +package com.onthelevel.core.sensors + +import kotlin.math.tan + +/** Converts a positive surface tilt into the conventional rise-over-run units. */ +object RiseRun { + fun millimetersPerMeter(angleDegrees: Double): Double = + tan(Math.toRadians(angleDegrees)) * MILLIMETERS_PER_METER + + fun inchesPerFoot(angleDegrees: Double): Double = + tan(Math.toRadians(angleDegrees)) * INCHES_PER_FOOT + + private const val MILLIMETERS_PER_METER = 1_000.0 + private const val INCHES_PER_FOOT = 12.0 +} diff --git a/app/src/main/java/com/onthelevel/core/settings/MeasurementUnits.kt b/app/src/main/java/com/onthelevel/core/settings/MeasurementUnits.kt new file mode 100644 index 0000000..7020c48 --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/settings/MeasurementUnits.kt @@ -0,0 +1,7 @@ +package com.onthelevel.core.settings + +/** Display units for construction-oriented slope guidance. */ +enum class MeasurementUnits { + METRIC, + IMPERIAL, +} diff --git a/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt b/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt index 2bae321..2f5b42b 100644 --- a/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt +++ b/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.doublePreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.onthelevel.core.sensors.EdgeCalibration import com.onthelevel.core.sensors.SurfaceCalibration @@ -42,6 +43,14 @@ class SettingsRepository(context: Context) { /** In-app reduced-motion preference; the system animator-scale signal is respected separately. */ val reducedMotion: Flow = store.data.map { it[Keys.REDUCED_MOTION] ?: false } + /** Kept in core so every future measurement tool formats slope consistently. */ + val measurementUnits: Flow = store.data.map { prefs -> + when (prefs[Keys.MEASUREMENT_UNITS]) { + MeasurementUnits.IMPERIAL.name -> MeasurementUnits.IMPERIAL + else -> MeasurementUnits.METRIC + } + } + suspend fun setSurfaceCalibration(calibration: SurfaceCalibration) { store.edit { it[Keys.SURFACE_PITCH_BIAS] = calibration.pitchBiasDegrees @@ -70,6 +79,10 @@ class SettingsRepository(context: Context) { store.edit { it[Keys.REDUCED_MOTION] = enabled } } + suspend fun setMeasurementUnits(units: MeasurementUnits) { + store.edit { it[Keys.MEASUREMENT_UNITS] = units.name } + } + // TODO(ruler): screen-ruler scale keyed by display identity/characteristics (BRIEF.md). private object Keys { @@ -80,5 +93,6 @@ class SettingsRepository(context: Context) { val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled") val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled") val REDUCED_MOTION = booleanPreferencesKey("reduced_motion") + val MEASUREMENT_UNITS = stringPreferencesKey("measurement_units") } } diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt index 1d5644a..9a3d9da 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt @@ -10,6 +10,7 @@ import com.onthelevel.core.sensors.OrientationMath import com.onthelevel.core.sensors.SurfaceCalibration import com.onthelevel.core.sensors.SurfacePresentation import com.onthelevel.core.sensors.SurfacePresentationDetector +import com.onthelevel.core.sensors.SettlingDetector /** * One reading through the raw → stable pipeline (BRIEF.md values 1 and 2 of 3). @@ -31,6 +32,8 @@ data class LevelReading( val surfacePresentation: SurfacePresentation? = null, /** False when the device is not physically in the selected mode's geometry. */ val placementOk: Boolean, + /** Surface-only shared motion gate; true while the phone is still settling. */ + val isSettling: Boolean = false, val isLocked: Boolean, val fireFeedback: Boolean, ) @@ -61,6 +64,7 @@ class LevelPipeline( private val aDeadband = DisplayDeadband() private val bDeadband = DisplayDeadband() private val surfacePresentationDetector = SurfacePresentationDetector() + private val settlingDetector = SettlingDetector() private var lastTimestampNanos: Long? = null fun process(g: GravitySample): LevelReading { @@ -82,6 +86,7 @@ class LevelPipeline( ) val displayMagnitude = primaryDeadband.update(magnitude) val presentation = surfacePresentationDetector.update(displayMagnitude) + val settling = settlingDetector.update(pitch, roll, g.timestampNanos) val lock = lockDetector.update(magnitude, nowMillis) LevelReading( mode = mode, @@ -100,6 +105,7 @@ class LevelPipeline( stableSurfaceRollDegrees = roll, surfacePresentation = presentation, placementOk = placementOk, + isSettling = settling.isSettling, isLocked = lock.isLocked, fireFeedback = lock.fireFeedback, ) diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt index 3dcf003..53e3297 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt @@ -7,8 +7,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.outlined.Settings @@ -39,9 +41,12 @@ import com.onthelevel.core.design.LevelColors import com.onthelevel.core.sensors.EdgeCalibration import com.onthelevel.core.sensors.LevelMode import com.onthelevel.core.sensors.LockDetector +import com.onthelevel.core.sensors.RiseRun import com.onthelevel.core.sensors.SensorSource import com.onthelevel.core.sensors.SurfaceCalibration +import com.onthelevel.core.sensors.SurfaceGuidance import com.onthelevel.core.sensors.SurfacePresentation +import com.onthelevel.core.settings.MeasurementUnits import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import java.util.Locale @@ -72,6 +77,8 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) { .collectAsStateWithLifecycle(initialValue = true) val reducedMotion by container.settings.reducedMotion .collectAsStateWithLifecycle(initialValue = false) + val measurementUnits by container.settings.measurementUnits + .collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC) val view = LocalView.current val lockDetector = remember { LockDetector() } @@ -174,6 +181,10 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) { }, textAlign = TextAlign.Center, ) + if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) { + Spacer(Modifier.height(8.dp)) + SurfaceAdjustmentInfo(reading, measurementUnits) + } } } @@ -195,6 +206,43 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) { } } +/** + * Surface-only adjustment guidance. Direction comes exclusively from + * [SurfaceGuidance] using the displayed (deadbanded) axes, so the wording has + * the same hysteresis as the pitch and roll panels instead of flapping at a + * direction boundary. The shared pipeline settling gate deliberately defers a + * strong instruction while the phone is still being placed. + */ +@Composable +private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnits) { + if (reading == null) return + + if (reading.isSettling) { + Text( + text = "Settling", + style = MaterialTheme.typography.labelSmall, + color = LevelColors.TextDim, + ) + return + } + + val pitch = reading.secondaryADegrees ?: return + val roll = reading.secondaryBDegrees ?: return + val highLabel = SurfaceGuidance.from(pitch, roll).highLabel ?: return + + Text( + text = highLabel, + style = MaterialTheme.typography.headlineMedium, + color = LevelColors.Amber, + textAlign = TextAlign.Center, + ) + Text( + text = formatRiseRun(reading.displayPrimaryDegrees, units), + style = MaterialTheme.typography.labelSmall, + color = LevelColors.TextDim, + ) +} + @Composable private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) { Surface( @@ -237,6 +285,13 @@ private fun statusLine(isCalibrated: Boolean, kind: SensorSource.Kind): String { internal fun formatDegrees(value: Double): String = String.format(Locale.US, "%.1f°", value) +internal fun formatRiseRun(valueDegrees: Double, units: MeasurementUnits): String = when (units) { + MeasurementUnits.METRIC -> + String.format(Locale.US, "%.1f mm/m", RiseRun.millimetersPerMeter(valueDegrees)) + MeasurementUnits.IMPERIAL -> + String.format(Locale.US, "%.2f in/ft", RiseRun.inchesPerFoot(valueDegrees)) +} + private fun formatTolerance(): String = String.format(Locale.US, "%.2f°", LockDetector.DEFAULT_EXIT_DEGREES) diff --git a/app/src/test/java/com/onthelevel/core/sensors/RiseRunTest.kt b/app/src/test/java/com/onthelevel/core/sensors/RiseRunTest.kt new file mode 100644 index 0000000..704c6c3 --- /dev/null +++ b/app/src/test/java/com/onthelevel/core/sensors/RiseRunTest.kt @@ -0,0 +1,19 @@ +package com.onthelevel.core.sensors + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RiseRunTest { + + @Test + fun `zero tilt has no rise over run`() { + assertEquals(0.0, RiseRun.millimetersPerMeter(0.0), 1e-9) + assertEquals(0.0, RiseRun.inchesPerFoot(0.0), 1e-9) + } + + @Test + fun `forty five degrees converts to the conventional construction slopes`() { + assertEquals(1_000.0, RiseRun.millimetersPerMeter(45.0), 1e-9) + assertEquals(12.0, RiseRun.inchesPerFoot(45.0), 1e-9) + } +} diff --git a/app/src/test/java/com/onthelevel/feature/level/LevelPipelineTest.kt b/app/src/test/java/com/onthelevel/feature/level/LevelPipelineTest.kt index 74f90c9..7fecbfe 100644 --- a/app/src/test/java/com/onthelevel/feature/level/LevelPipelineTest.kt +++ b/app/src/test/java/com/onthelevel/feature/level/LevelPipelineTest.kt @@ -111,6 +111,14 @@ class LevelPipelineTest { assertTrue(readings.none { it.fireFeedback }) } + @Test + fun `surface reading exposes the shared settling state before strong guidance`() { + val readings = run(surfacePipeline(), degrees = 2.0, fromMillis = 0, untilMillis = 1_000) + + assertTrue(readings.first().isSettling) + assertFalse(readings.last().isSettling) + } + @Test fun `edge mode never locks while the phone lies flat on a table`() { val pipeline = LevelPipeline(