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.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.doublePreferencesKey
|
import androidx.datastore.preferences.core.doublePreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.onthelevel.core.sensors.EdgeCalibration
|
import com.onthelevel.core.sensors.EdgeCalibration
|
||||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
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. */
|
/** 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 }
|
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) {
|
suspend fun setSurfaceCalibration(calibration: SurfaceCalibration) {
|
||||||
store.edit {
|
store.edit {
|
||||||
it[Keys.SURFACE_PITCH_BIAS] = calibration.pitchBiasDegrees
|
it[Keys.SURFACE_PITCH_BIAS] = calibration.pitchBiasDegrees
|
||||||
@@ -70,6 +79,10 @@ class SettingsRepository(context: Context) {
|
|||||||
store.edit { it[Keys.REDUCED_MOTION] = enabled }
|
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).
|
// TODO(ruler): screen-ruler scale keyed by display identity/characteristics (BRIEF.md).
|
||||||
|
|
||||||
private object Keys {
|
private object Keys {
|
||||||
@@ -80,5 +93,6 @@ class SettingsRepository(context: Context) {
|
|||||||
val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled")
|
val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled")
|
||||||
val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled")
|
val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled")
|
||||||
val REDUCED_MOTION = booleanPreferencesKey("reduced_motion")
|
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.SurfaceCalibration
|
||||||
import com.onthelevel.core.sensors.SurfacePresentation
|
import com.onthelevel.core.sensors.SurfacePresentation
|
||||||
import com.onthelevel.core.sensors.SurfacePresentationDetector
|
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).
|
* 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,
|
val surfacePresentation: SurfacePresentation? = null,
|
||||||
/** False when the device is not physically in the selected mode's geometry. */
|
/** False when the device is not physically in the selected mode's geometry. */
|
||||||
val placementOk: Boolean,
|
val placementOk: Boolean,
|
||||||
|
/** Surface-only shared motion gate; true while the phone is still settling. */
|
||||||
|
val isSettling: Boolean = false,
|
||||||
val isLocked: Boolean,
|
val isLocked: Boolean,
|
||||||
val fireFeedback: Boolean,
|
val fireFeedback: Boolean,
|
||||||
)
|
)
|
||||||
@@ -61,6 +64,7 @@ class LevelPipeline(
|
|||||||
private val aDeadband = DisplayDeadband()
|
private val aDeadband = DisplayDeadband()
|
||||||
private val bDeadband = DisplayDeadband()
|
private val bDeadband = DisplayDeadband()
|
||||||
private val surfacePresentationDetector = SurfacePresentationDetector()
|
private val surfacePresentationDetector = SurfacePresentationDetector()
|
||||||
|
private val settlingDetector = SettlingDetector()
|
||||||
private var lastTimestampNanos: Long? = null
|
private var lastTimestampNanos: Long? = null
|
||||||
|
|
||||||
fun process(g: GravitySample): LevelReading {
|
fun process(g: GravitySample): LevelReading {
|
||||||
@@ -82,6 +86,7 @@ class LevelPipeline(
|
|||||||
)
|
)
|
||||||
val displayMagnitude = primaryDeadband.update(magnitude)
|
val displayMagnitude = primaryDeadband.update(magnitude)
|
||||||
val presentation = surfacePresentationDetector.update(displayMagnitude)
|
val presentation = surfacePresentationDetector.update(displayMagnitude)
|
||||||
|
val settling = settlingDetector.update(pitch, roll, g.timestampNanos)
|
||||||
val lock = lockDetector.update(magnitude, nowMillis)
|
val lock = lockDetector.update(magnitude, nowMillis)
|
||||||
LevelReading(
|
LevelReading(
|
||||||
mode = mode,
|
mode = mode,
|
||||||
@@ -100,6 +105,7 @@ class LevelPipeline(
|
|||||||
stableSurfaceRollDegrees = roll,
|
stableSurfaceRollDegrees = roll,
|
||||||
surfacePresentation = presentation,
|
surfacePresentation = presentation,
|
||||||
placementOk = placementOk,
|
placementOk = placementOk,
|
||||||
|
isSettling = settling.isSettling,
|
||||||
isLocked = lock.isLocked,
|
isLocked = lock.isLocked,
|
||||||
fireFeedback = lock.fireFeedback,
|
fireFeedback = lock.fireFeedback,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import androidx.compose.foundation.layout.Arrangement
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
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.EdgeCalibration
|
||||||
import com.onthelevel.core.sensors.LevelMode
|
import com.onthelevel.core.sensors.LevelMode
|
||||||
import com.onthelevel.core.sensors.LockDetector
|
import com.onthelevel.core.sensors.LockDetector
|
||||||
|
import com.onthelevel.core.sensors.RiseRun
|
||||||
import com.onthelevel.core.sensors.SensorSource
|
import com.onthelevel.core.sensors.SensorSource
|
||||||
import com.onthelevel.core.sensors.SurfaceCalibration
|
import com.onthelevel.core.sensors.SurfaceCalibration
|
||||||
|
import com.onthelevel.core.sensors.SurfaceGuidance
|
||||||
import com.onthelevel.core.sensors.SurfacePresentation
|
import com.onthelevel.core.sensors.SurfacePresentation
|
||||||
|
import com.onthelevel.core.settings.MeasurementUnits
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -72,6 +77,8 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
|
|||||||
.collectAsStateWithLifecycle(initialValue = true)
|
.collectAsStateWithLifecycle(initialValue = true)
|
||||||
val reducedMotion by container.settings.reducedMotion
|
val reducedMotion by container.settings.reducedMotion
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val measurementUnits by container.settings.measurementUnits
|
||||||
|
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
|
||||||
|
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
val lockDetector = remember { LockDetector() }
|
val lockDetector = remember { LockDetector() }
|
||||||
@@ -174,6 +181,10 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
|
|||||||
},
|
},
|
||||||
textAlign = TextAlign.Center,
|
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
|
@Composable
|
||||||
private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
|
private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) {
|
||||||
Surface(
|
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 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 =
|
private fun formatTolerance(): String =
|
||||||
String.format(Locale.US, "%.2f°", LockDetector.DEFAULT_EXIT_DEGREES)
|
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 })
|
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
|
@Test
|
||||||
fun `edge mode never locks while the phone lies flat on a table`() {
|
fun `edge mode never locks while the phone lies flat on a table`() {
|
||||||
val pipeline = LevelPipeline(
|
val pipeline = LevelPipeline(
|
||||||
|
|||||||
Reference in New Issue
Block a user