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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user