Calibration UX overhaul + Settings screen (Claude, driving)

Fixes a real defect and reworks the calibration/settings flow per Jay's
on-device review.

Bug: the second capture hung on 'capturing'. The completion code wrote
capture=null (a LaunchedEffect key) then awaited the suspend
setSurfaceCalibration BEFORE setting phase=COMPLETE; at the suspension
Compose cancelled the effect mid-write, stranding the transition. The
effect body is now fully synchronous and persistence runs on an
independent scope.launch. Verified end-to-end on device.

Settings screen (new): reached from the Level header gear (was: gear
jumped straight into calibration). Calibration is a sleek button in its
own section; Units, Haptics, and Reduce-motion prefs get a home; Done is
pinned to the bottom while the list scrolls independently.

Calibration screen: two stages. OVERVIEW shows the current correction
(Pitch/Roll offsets), the Thoroughness selector, and Calibrate / Reset
to phone defaults / Done. CAPTURING shows ONLY capture + Cancel; Cancel
restores the prior calibration (saved value is never touched mid-flow).
Instructions centered; outlined buttons given visible borders.

Thoroughness: Simple (2-point, 0/180) or Thorough (4-point,
0/90/180/270). Generalized deriveSurfaceFromSamples averages a symmetric
rotation set - the true tilt sums to zero, so the mean is the device
bias; 4-point also cancels each axis twice and averages more noise. Two
new unit tests (2-point equivalence, 4-point bias recovery).

61 tests passing; assembleDebug clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jay
2026-07-14 10:37:35 -04:00
parent 721fccd5ce
commit 85a8ce1136
8 changed files with 523 additions and 114 deletions
@@ -28,6 +28,7 @@ import com.onthelevel.feature.angle.AngleScreen
import com.onthelevel.feature.level.LevelScreen import com.onthelevel.feature.level.LevelScreen
import com.onthelevel.feature.level.SurfaceCalibrationScreen import com.onthelevel.feature.level.SurfaceCalibrationScreen
import com.onthelevel.feature.ruler.RulerScreen import com.onthelevel.feature.ruler.RulerScreen
import com.onthelevel.feature.tools.SettingsScreen
import com.onthelevel.feature.tools.ToolsScreen import com.onthelevel.feature.tools.ToolsScreen
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@@ -89,8 +90,8 @@ private fun AppRoot(container: AppContainer) {
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) { ) {
composable("level") { composable("level") {
LevelScreen(container, onOpenSurfaceCalibration = { LevelScreen(container, onOpenSettings = {
navController.navigate("surface-calibration") navController.navigate("settings")
}) })
} }
composable("angle") { AngleScreen(container) } composable("angle") { AngleScreen(container) }
@@ -98,6 +99,13 @@ private fun AppRoot(container: AppContainer) {
ToolsScreen(onOpenRuler = { navController.navigate("ruler") }) ToolsScreen(onOpenRuler = { navController.navigate("ruler") })
} }
composable("ruler") { RulerScreen(container) } composable("ruler") { RulerScreen(container) }
composable("settings") {
SettingsScreen(
container,
onOpenSurfaceCalibration = { navController.navigate("surface-calibration") },
onBack = { navController.popBackStack() },
)
}
composable("surface-calibration") { composable("surface-calibration") {
SurfaceCalibrationScreen(container, onBack = { navController.popBackStack() }) SurfaceCalibrationScreen(container, onBack = { navController.popBackStack() })
} }
@@ -47,6 +47,22 @@ object TwoSampleCalibration {
rollBiasDegrees = deriveBiasDegrees(roll1, roll2), rollBiasDegrees = deriveBiasDegrees(roll1, roll2),
) )
/**
* Generalized bias derivation for a SYMMETRIC set of rotations about the surface
* normal — 2-point (0°/180°) or 4-point (0°/90°/180°/270°). The surface's true tilt
* is a sinusoid in rotation angle, so it sums to zero over any such symmetric set;
* the mean of the readings is therefore the fixed device bias. The 4-point set also
* cancels each axis with both a 180° opposite and a 90° pair, so it tolerates an
* imperfect turn better and averages out more sensor noise (thorough mode).
*/
fun deriveSurfaceFromSamples(samples: List<CapturedSurfaceSample>): SurfaceCalibration {
require(samples.isNotEmpty()) { "Calibration needs at least one captured sample" }
return SurfaceCalibration(
pitchBiasDegrees = samples.sumOf { it.pitchDegrees } / samples.size,
rollBiasDegrees = samples.sumOf { it.rollDegrees } / samples.size,
)
}
fun deriveEdge( fun deriveEdge(
level1: Double, level1: Double,
level2: Double, level2: Double,
@@ -0,0 +1,11 @@
package com.onthelevel.core.settings
/**
* How thorough the guided Surface calibration is. Both use a symmetric rotation set
* about the surface normal so the mean of the readings is the device bias; THOROUGH
* adds the 90°/270° pair to better tolerate an imperfect turn and average more noise.
*/
enum class CalibrationMode(val captureCount: Int) {
SIMPLE(2),
THOROUGH(4),
}
@@ -51,6 +51,13 @@ class SettingsRepository(context: Context) {
} }
} }
val calibrationMode: Flow<CalibrationMode> = store.data.map { prefs ->
when (prefs[Keys.CALIBRATION_MODE]) {
CalibrationMode.THOROUGH.name -> CalibrationMode.THOROUGH
else -> CalibrationMode.SIMPLE
}
}
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
@@ -83,6 +90,10 @@ class SettingsRepository(context: Context) {
store.edit { it[Keys.MEASUREMENT_UNITS] = units.name } store.edit { it[Keys.MEASUREMENT_UNITS] = units.name }
} }
suspend fun setCalibrationMode(mode: CalibrationMode) {
store.edit { it[Keys.CALIBRATION_MODE] = mode.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 {
@@ -94,5 +105,6 @@ class SettingsRepository(context: Context) {
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") val MEASUREMENT_UNITS = stringPreferencesKey("measurement_units")
val CALIBRATION_MODE = stringPreferencesKey("calibration_mode")
} }
} }
@@ -70,7 +70,7 @@ import java.util.Locale
* while the portrait-locked device stands on its long edge. * while the portrait-locked device stands on its long edge.
*/ */
@Composable @Composable
fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) { fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) {
KeepScreenOn() KeepScreenOn()
val sensorSource = container.sensorSource val sensorSource = container.sensorSource
@@ -141,10 +141,10 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
color = if (audioCueEnabled) LevelColors.LimeLockText else LevelColors.TextDim, color = if (audioCueEnabled) LevelColors.LimeLockText else LevelColors.TextDim,
) )
} }
IconButton(onClick = onOpenSurfaceCalibration) { IconButton(onClick = onOpenSettings) {
Icon( Icon(
Icons.Outlined.Settings, Icons.Outlined.Settings,
contentDescription = "Calibration and settings", contentDescription = "Settings",
tint = LevelColors.TextDim, tint = LevelColors.TextDim,
) )
} }
@@ -1,15 +1,21 @@
package com.onthelevel.feature.level package com.onthelevel.feature.level
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
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.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -35,14 +41,21 @@ import com.onthelevel.core.sensors.SurfaceCalibration
import com.onthelevel.core.sensors.SurfaceCalibrationSample import com.onthelevel.core.sensors.SurfaceCalibrationSample
import com.onthelevel.core.sensors.SurfaceCalibrationValidation import com.onthelevel.core.sensors.SurfaceCalibrationValidation
import com.onthelevel.core.sensors.TwoSampleCalibration import com.onthelevel.core.sensors.TwoSampleCalibration
import com.onthelevel.core.settings.CalibrationMode
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale
private enum class CalibrationPhase { FIRST_READY, FIRST_CAPTURING, SECOND_READY, SECOND_CAPTURING, COMPLETE, ERROR } private enum class Stage { OVERVIEW, CAPTURING }
private enum class CapturePhase { READY, RUNNING, ERROR }
/** /**
* Guided two-sample Surface calibration. Capture reads SensorSource.gravity directly * Surface calibration. OVERVIEW shows the current correction and lets the user start,
* and never applies stored SurfaceCalibration to either sample. * reset, or leave. CAPTURING runs the guided flow and shows ONLY calibration controls;
* Cancel returns to OVERVIEW without touching the saved calibration, so exploring or
* backing out never changes the current setting.
*
* Captures read SensorSource.gravity directly and never apply the stored calibration.
*/ */
@Composable @Composable
fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) { fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
@@ -50,12 +63,19 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
val sensorSource = container.sensorSource val sensorSource = container.sensorSource
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var phase by remember { mutableStateOf(CalibrationPhase.FIRST_READY) } val mode by container.settings.calibrationMode
var firstCapture by remember { mutableStateOf<CapturedSurfaceSample?>(null) } .collectAsStateWithLifecycle(initialValue = CalibrationMode.SIMPLE)
var capture by remember { mutableStateOf<StableSurfaceCapture?>(null) } val totalSteps = mode.captureCount
var error by remember { mutableStateOf<String?>(null) }
val existingCalibration by container.settings.surfaceCalibration val existingCalibration by container.settings.surfaceCalibration
.collectAsStateWithLifecycle(initialValue = SurfaceCalibration.NONE) .collectAsStateWithLifecycle(initialValue = SurfaceCalibration.NONE)
val isCalibrated = existingCalibration != SurfaceCalibration.NONE
var stage by remember { mutableStateOf(Stage.OVERVIEW) }
var phase by remember { mutableStateOf(CapturePhase.READY) }
var captures by remember { mutableStateOf<List<CapturedSurfaceSample>>(emptyList()) }
var capture by remember { mutableStateOf<StableSurfaceCapture?>(null) }
var error by remember { mutableStateOf<String?>(null) }
var justSaved by remember { mutableStateOf(false) }
val rawReadingFlow = remember(sensorSource) { val rawReadingFlow = remember(sensorSource) {
val pitchEma = Ema(0.15) val pitchEma = Ema(0.15)
@@ -75,129 +95,287 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
} }
val reading by rawReadingFlow.collectAsStateWithLifecycle(initialValue = null) val reading by rawReadingFlow.collectAsStateWithLifecycle(initialValue = null)
LaunchedEffect(reading, phase, capture, firstCapture) { // Fully synchronous body: persistence goes to `scope`, never awaited here. Writing a
// key (capture/phase/stage) and then suspending inside would let Compose cancel the
// effect mid-write and strand the transition.
LaunchedEffect(reading, stage, phase, capture) {
if (stage != Stage.CAPTURING || phase != CapturePhase.RUNNING) return@LaunchedEffect
val activeCapture = capture ?: return@LaunchedEffect val activeCapture = capture ?: return@LaunchedEffect
val current = reading ?: return@LaunchedEffect val current = reading ?: return@LaunchedEffect
val captured = activeCapture.add(current) ?: return@LaunchedEffect val captured = activeCapture.add(current) ?: return@LaunchedEffect
when (phase) {
CalibrationPhase.FIRST_CAPTURING -> { val newCaptures = captures + captured
firstCapture = captured capture = null
capture = null if (newCaptures.size < totalSteps) {
phase = CalibrationPhase.SECOND_READY captures = newCaptures
} phase = CapturePhase.READY
CalibrationPhase.SECOND_CAPTURING -> { return@LaunchedEffect
val first = firstCapture ?: return@LaunchedEffect }
val calibration = TwoSampleCalibration.deriveSurface( val calibration = TwoSampleCalibration.deriveSurfaceFromSamples(newCaptures)
first.pitchDegrees, if (!SurfaceCalibrationValidation.isAcceptable(calibration)) {
first.rollDegrees, captures = emptyList()
captured.pitchDegrees, error = "Those positions didn't agree. Keep the phone on the same spot, turn it in place, and don't flip it over."
captured.rollDegrees, phase = CapturePhase.ERROR
) } else {
capture = null captures = emptyList()
if (!SurfaceCalibrationValidation.isAcceptable(calibration)) { phase = CapturePhase.READY
error = "The placements did not agree. Keep the phone on the same spot and rotate it exactly halfway around." stage = Stage.OVERVIEW
phase = CalibrationPhase.ERROR justSaved = true
} else { scope.launch { container.settings.setSurfaceCalibration(calibration) }
container.settings.setSurfaceCalibration(calibration)
phase = CalibrationPhase.COMPLETE
}
}
else -> Unit
} }
} }
val canStartCapture = reading?.let { fun startCalibrating() {
!it.isSettling && it.tiltDegrees <= StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES captures = emptyList()
} ?: false error = null
val status = when { justSaved = false
reading == null -> "Waiting for a sensor reading" phase = CapturePhase.READY
reading!!.isSettling -> "Settling—keep the phone still" stage = Stage.CAPTURING
reading!!.tiltDegrees > StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES ->
"Find a flatter surface (under 5°)"
else -> "Ready to capture"
} }
Column( fun cancelCalibrating() {
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp), // Saved calibration is never touched mid-flow, so this simply restores it.
verticalArrangement = Arrangement.spacedBy(18.dp), captures = emptyList()
) { capture = null
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { error = null
phase = CapturePhase.READY
stage = Stage.OVERVIEW
}
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text("SURFACE CALIBRATION", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim) Text("SURFACE CALIBRATION", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
OutlinedButton(onClick = onBack) { Text("Done") } if (stage == Stage.CAPTURING) {
Text(
text = if (mode == CalibrationMode.THOROUGH) "THOROUGH" else "SIMPLE",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.Amber,
)
}
} }
Text(calibrationInstruction(phase), style = MaterialTheme.typography.headlineMedium, color = LevelColors.TextPrimary)
if (stage == Stage.OVERVIEW) {
OverviewContent(
modifier = Modifier.weight(1f).fillMaxWidth(),
isCalibrated = isCalibrated,
calibration = existingCalibration,
justSaved = justSaved,
mode = mode,
onModeChange = { scope.launch { container.settings.setCalibrationMode(it) } },
)
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = ::startCalibrating, modifier = Modifier.fillMaxWidth()) {
Text(if (isCalibrated) "Recalibrate" else "Calibrate now")
}
if (isCalibrated) {
CalibrationOutlinedButton(
text = "Reset to phone defaults",
onClick = {
justSaved = false
scope.launch { container.settings.clearSurfaceCalibration() }
},
modifier = Modifier.fillMaxWidth(),
)
}
CalibrationOutlinedButton(text = "Done", onClick = onBack, modifier = Modifier.fillMaxWidth())
}
} else {
CapturingContent(
modifier = Modifier.weight(1f).fillMaxWidth(),
phase = phase,
stepIndex = captures.size,
totalSteps = totalSteps,
mode = mode,
error = error,
tiltText = reading?.let { formatDegrees(it.tiltDegrees) } ?: "",
status = captureStatus(reading?.isSettling, reading?.tiltDegrees, reading != null),
)
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
when (phase) {
CapturePhase.READY -> {
val canStart = reading?.let {
!it.isSettling && it.tiltDegrees <= StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES
} ?: false
Button(
onClick = {
capture = StableSurfaceCapture()
phase = CapturePhase.RUNNING
},
enabled = canStart,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (captures.isEmpty()) "Capture first position" else "Capture position ${captures.size + 1}")
}
}
CapturePhase.RUNNING -> Text(
text = "Hold still — capturing 1.5 seconds…",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = LevelColors.TextDim,
)
CapturePhase.ERROR -> Button(
onClick = {
captures = emptyList()
error = null
phase = CapturePhase.READY
},
modifier = Modifier.fillMaxWidth(),
) { Text("Start over") }
}
CalibrationOutlinedButton(text = "Cancel", onClick = ::cancelCalibrating, modifier = Modifier.fillMaxWidth())
}
}
}
}
@Composable
private fun OverviewContent(
modifier: Modifier,
isCalibrated: Boolean,
calibration: SurfaceCalibration,
justSaved: Boolean,
mode: CalibrationMode,
onModeChange: (CalibrationMode) -> Unit,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text( Text(
"This corrects your phone and case setup. It does not make this surface level.", text = if (justSaved) "CALIBRATION SAVED" else "CURRENT CORRECTION",
style = MaterialTheme.typography.labelSmall,
color = if (justSaved) LevelColors.LimeLockText else LevelColors.TextDim,
)
Spacer(Modifier.height(10.dp))
if (isCalibrated) {
Text(
"Pitch ${signedDegrees(calibration.pitchBiasDegrees)}",
style = MaterialTheme.typography.headlineMedium,
color = LevelColors.TextPrimary,
)
Text(
"Roll ${signedDegrees(calibration.rollBiasDegrees)}",
style = MaterialTheme.typography.headlineMedium,
color = LevelColors.TextPrimary,
)
} else {
Text(
"Using phone defaults",
style = MaterialTheme.typography.headlineMedium,
color = LevelColors.TextPrimary,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(10.dp))
Text(
text = "Calibration corrects your phone-and-case setup. It does not make a surface level.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = LevelColors.TextDim, color = LevelColors.TextDim,
textAlign = TextAlign.Center,
) )
Spacer(Modifier.height(28.dp))
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { Text("THOROUGHNESS", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
Column(horizontalAlignment = Alignment.CenterHorizontally) { Spacer(Modifier.height(8.dp))
Text( SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
reading?.let { formatDegrees(it.tiltDegrees) } ?: "", CalibrationMode.entries.forEachIndexed { index, entry ->
style = MaterialTheme.typography.displayLarge, SegmentedButton(
color = LevelColors.TextPrimary, selected = mode == entry,
) onClick = { onModeChange(entry) },
Text(status, style = MaterialTheme.typography.bodyLarge, color = LevelColors.Amber) shape = SegmentedButtonDefaults.itemShape(index, CalibrationMode.entries.size),
) {
Text(if (entry == CalibrationMode.SIMPLE) "Simple · 2 pt" else "Thorough · 4 pt")
}
} }
} }
Text(
text = if (mode == CalibrationMode.THOROUGH) {
"Four quarter-turns. Slower, but averages out more error."
} else {
"Two positions, half a turn apart. Quick and dependable."
},
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp),
)
}
}
when (phase) { @Composable
CalibrationPhase.FIRST_READY, CalibrationPhase.SECOND_READY -> Button( private fun CapturingContent(
onClick = { modifier: Modifier,
capture = StableSurfaceCapture() phase: CapturePhase,
phase = if (phase == CalibrationPhase.FIRST_READY) CalibrationPhase.FIRST_CAPTURING else CalibrationPhase.SECOND_CAPTURING stepIndex: Int,
}, totalSteps: Int,
enabled = canStartCapture, mode: CalibrationMode,
modifier = Modifier.fillMaxWidth(), error: String?,
) { tiltText: String,
Text(if (phase == CalibrationPhase.FIRST_READY) "Capture first position" else "Capture rotated position") status: String,
} ) {
CalibrationPhase.FIRST_CAPTURING, CalibrationPhase.SECOND_CAPTURING -> Text( Column(
"Capturing 1.5 seconds of still readings…", modifier = modifier,
modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally,
textAlign = TextAlign.Center, verticalArrangement = Arrangement.Center,
style = MaterialTheme.typography.bodyLarge, ) {
if (phase != CapturePhase.ERROR) {
Text(
"POSITION ${stepIndex + 1} OF $totalSteps",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim, color = LevelColors.TextDim,
) )
CalibrationPhase.COMPLETE -> { Spacer(Modifier.height(14.dp))
Text(
"Surface calibration saved for this phone/case setup.",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = LevelColors.LimeLockText,
)
Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { Text("Back to level") }
}
CalibrationPhase.ERROR -> {
Text(error ?: "Calibration could not be completed.", style = MaterialTheme.typography.bodyLarge, color = LevelColors.Amber)
Button(
onClick = {
firstCapture = null
error = null
phase = CalibrationPhase.FIRST_READY
},
modifier = Modifier.fillMaxWidth(),
) { Text("Try again") }
}
} }
Text(
if (existingCalibration != SurfaceCalibration.NONE && phase != CalibrationPhase.FIRST_CAPTURING && phase != CalibrationPhase.SECOND_CAPTURING) { text = when {
OutlinedButton( phase == CapturePhase.ERROR -> error ?: "Let's try that again"
onClick = { scope.launch { container.settings.clearSurfaceCalibration() } }, stepIndex == 0 -> "Lay the phone screen-up on a firm, roughly level surface. Keep it still."
modifier = Modifier.fillMaxWidth(), mode == CalibrationMode.SIMPLE ->
) { Text("Clear saved calibration") } "Spin the phone a half-turn (180°) in the same spot. Don't lift or flip it over."
else ->
"Spin the phone a quarter-turn (90°) the same direction, same spot. Don't lift or flip it over."
},
style = MaterialTheme.typography.headlineMedium,
color = if (phase == CapturePhase.ERROR) LevelColors.Amber else LevelColors.TextPrimary,
textAlign = TextAlign.Center,
)
if (phase != CapturePhase.ERROR) {
Spacer(Modifier.height(32.dp))
Text(tiltText, style = MaterialTheme.typography.displaySmall, color = LevelColors.TextPrimary)
Text(status, style = MaterialTheme.typography.bodyLarge, color = LevelColors.Amber, textAlign = TextAlign.Center)
} }
} }
} }
private fun calibrationInstruction(phase: CalibrationPhase): String = when (phase) { private fun captureStatus(isSettling: Boolean?, tiltDegrees: Double?, hasReading: Boolean): String = when {
CalibrationPhase.FIRST_READY, CalibrationPhase.FIRST_CAPTURING -> !hasReading -> "Waiting for a sensor reading"
"Lay the phone screen-up on a firm, roughly level surface. Keep it still." isSettling == true -> "Settling — keep the phone still"
CalibrationPhase.SECOND_READY, CalibrationPhase.SECOND_CAPTURING -> tiltDegrees != null && tiltDegrees > StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES ->
"Rotate the phone exactly 180° in the same plane. Do not flip it over or move it." "Find a flatter surface (under 5°)"
CalibrationPhase.COMPLETE -> "Calibration complete" else -> "Ready to capture"
CalibrationPhase.ERROR -> "Try Surface calibration again" }
/** Outlined buttons default to a near-invisible outline on graphite; give them a real border. */
@Composable
private fun CalibrationOutlinedButton(text: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
OutlinedButton(
onClick = onClick,
modifier = modifier,
border = BorderStroke(1.5.dp, LevelColors.Amber.copy(alpha = .65f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = LevelColors.Amber),
) { Text(text) }
}
private fun signedDegrees(value: Double): String {
val rounded = Math.round(value * 10.0) / 10.0
return when {
rounded == 0.0 -> "0.0°" // never render a signed negative zero
rounded > 0.0 -> String.format(Locale.US, "+%.1f°", rounded)
else -> String.format(Locale.US, "%.1f°", rounded)
}
} }
@@ -0,0 +1,155 @@
package com.onthelevel.feature.tools
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.onthelevel.AppContainer
import com.onthelevel.core.design.KeepScreenOn
import com.onthelevel.core.design.LevelColors
import com.onthelevel.core.settings.MeasurementUnits
import kotlinx.coroutines.launch
/**
* App settings, reached from the Level header gear. Calibration is a plain doorway
* here (its own screen owns status and controls); the measurement preferences live
* directly on this list. Done is pinned to the bottom so it stays put as the list grows.
*/
@Composable
fun SettingsScreen(
container: AppContainer,
onOpenSurfaceCalibration: () -> Unit,
onBack: () -> Unit,
) {
KeepScreenOn()
val scope = rememberCoroutineScope()
val units by container.settings.measurementUnits
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
val reducedMotion by container.settings.reducedMotion
.collectAsStateWithLifecycle(initialValue = false)
val hapticsEnabled by container.settings.hapticsEnabled
.collectAsStateWithLifecycle(initialValue = true)
Column(modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp)) {
Column(
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Text("SETTINGS", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
SettingsSection("Calibration") {
Button(
onClick = onOpenSurfaceCalibration,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = LevelColors.ReadoutPanel,
contentColor = LevelColors.Amber,
),
) { Text("Calibration Utility") }
}
SettingsSection("Units") {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
MeasurementUnits.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = units == entry,
onClick = { scope.launch { container.settings.setMeasurementUnits(entry) } },
shape = SegmentedButtonDefaults.itemShape(index, MeasurementUnits.entries.size),
) {
Text(if (entry == MeasurementUnits.METRIC) "Metric" else "Imperial")
}
}
}
}
SettingsSection("Feedback & motion") {
ToggleRow(
label = "Haptic feedback",
description = "A gentle buzz when a surface locks level.",
checked = hapticsEnabled,
onCheckedChange = { scope.launch { container.settings.setHapticsEnabled(it) } },
)
Spacer(Modifier.height(12.dp))
ToggleRow(
label = "Reduce motion",
description = "Snap the bubble instead of springing it.",
checked = reducedMotion,
onCheckedChange = { scope.launch { container.settings.setReducedMotion(it) } },
)
}
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onBack,
modifier = Modifier.fillMaxWidth(),
border = BorderStroke(1.5.dp, LevelColors.Amber.copy(alpha = .65f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = LevelColors.Amber),
) { Text("Done") }
}
}
@Composable
private fun SettingsSection(title: String, content: @Composable () -> Unit) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
color = LevelColors.TextPrimary,
modifier = Modifier.padding(bottom = 10.dp),
)
content()
}
}
@Composable
private fun ToggleRow(
label: String,
description: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(label, style = MaterialTheme.typography.bodyLarge, color = LevelColors.TextPrimary)
Text(description, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
}
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
colors = SwitchDefaults.colors(
checkedThumbColor = LevelColors.Graphite,
checkedTrackColor = LevelColors.Amber,
),
)
}
}
@@ -145,4 +145,33 @@ class TwoSampleCalibrationTest {
assertEquals(edgeSample, edge.apply(edgeSample)) assertEquals(edgeSample, edge.apply(edgeSample))
assertEquals(0.5, surface.pitchBiasDegrees, 1e-9) assertEquals(0.5, surface.pitchBiasDegrees, 1e-9)
} }
@Test
fun `two-sample averaging matches the pairwise derivation`() {
val fromSamples = TwoSampleCalibration.deriveSurfaceFromSamples(
listOf(CapturedSurfaceSample(0.8, -0.1), CapturedSurfaceSample(-0.2, 0.5)),
)
val pairwise = TwoSampleCalibration.deriveSurface(0.8, -0.1, -0.2, 0.5)
assertEquals(pairwise.pitchBiasDegrees, fromSamples.pitchBiasDegrees, 1e-9)
assertEquals(pairwise.rollBiasDegrees, fromSamples.rollBiasDegrees, 1e-9)
}
@Test
fun `four-point averaging recovers bias from a symmetric rotation set`() {
// Bias is fixed in the device frame; the true surface tilt appears as a
// symmetric set summing to zero over 0/90/180/270 (pitch: +t,0,-t,0 ;
// roll: 0,+t,0,-t). The mean must therefore be exactly the bias.
val biasPitch = 0.4
val biasRoll = -0.3
val t = 1.7
val samples = listOf(
CapturedSurfaceSample(biasPitch + t, biasRoll),
CapturedSurfaceSample(biasPitch, biasRoll + t),
CapturedSurfaceSample(biasPitch - t, biasRoll),
CapturedSurfaceSample(biasPitch, biasRoll - t),
)
val cal = TwoSampleCalibration.deriveSurfaceFromSamples(samples)
assertEquals(biasPitch, cal.pitchBiasDegrees, 1e-9)
assertEquals(biasRoll, cal.rollBiasDegrees, 1e-9)
}
} }