Slice 4: actionable adjustment guidance (Codex)

- SurfaceAdjustmentInfo: high-side instruction sourced exclusively from
  SurfaceGuidance.from(displayed pitch, displayed roll).highLabel - the
  deadbanded axes supply the label hysteresis, so wording cannot flap
  at direction boundaries.
- Shared SettlingDetector exposed through the pipeline as the sole
  motion gate; strong guidance defers behind a quiet Settling state.
- RiseRun (core/sensors, pure): tan-based mm/m and in/ft, derived from
  the same deadbanded magnitude as the numeric readout; pinned at
  45 degrees = 1000 mm/m = 12 in/ft.
- Persisted MeasurementUnits preference in core/settings (metric
  default); Tools UI control remains deferred per plan.
- Guidance renders only in face-up Surface mode, hidden at level
  (no direction below the 0.1-degree deadband).

56 tests passing.

Audited-by: Claude (no findings)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-13 15:43:42 -04:00
parent 422854de07
commit 027b9b96b3
7 changed files with 124 additions and 0 deletions
@@ -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
}
@@ -0,0 +1,7 @@
package com.onthelevel.core.settings
/** Display units for construction-oriented slope guidance. */
enum class MeasurementUnits {
METRIC,
IMPERIAL,
}
@@ -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<Boolean> = store.data.map { it[Keys.REDUCED_MOTION] ?: false }
/** Kept in core so every future measurement tool formats slope consistently. */
val measurementUnits: Flow<MeasurementUnits> = 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")
}
}
@@ -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,
)
@@ -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)
@@ -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)
}
}
@@ -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(