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.SurfaceCalibrationScreen
import com.onthelevel.feature.ruler.RulerScreen
import com.onthelevel.feature.tools.SettingsScreen
import com.onthelevel.feature.tools.ToolsScreen
class MainActivity : ComponentActivity() {
@@ -89,8 +90,8 @@ private fun AppRoot(container: AppContainer) {
modifier = Modifier.padding(padding),
) {
composable("level") {
LevelScreen(container, onOpenSurfaceCalibration = {
navController.navigate("surface-calibration")
LevelScreen(container, onOpenSettings = {
navController.navigate("settings")
})
}
composable("angle") { AngleScreen(container) }
@@ -98,6 +99,13 @@ private fun AppRoot(container: AppContainer) {
ToolsScreen(onOpenRuler = { navController.navigate("ruler") })
}
composable("ruler") { RulerScreen(container) }
composable("settings") {
SettingsScreen(
container,
onOpenSurfaceCalibration = { navController.navigate("surface-calibration") },
onBack = { navController.popBackStack() },
)
}
composable("surface-calibration") {
SurfaceCalibrationScreen(container, onBack = { navController.popBackStack() })
}
@@ -47,6 +47,22 @@ object TwoSampleCalibration {
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(
level1: 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) {
store.edit {
it[Keys.SURFACE_PITCH_BIAS] = calibration.pitchBiasDegrees
@@ -83,6 +90,10 @@ class SettingsRepository(context: Context) {
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).
private object Keys {
@@ -94,5 +105,6 @@ class SettingsRepository(context: Context) {
val AUDIO_CUE_ENABLED = booleanPreferencesKey("audio_cue_enabled")
val REDUCED_MOTION = booleanPreferencesKey("reduced_motion")
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.
*/
@Composable
fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
fun LevelScreen(container: AppContainer, onOpenSettings: () -> Unit) {
KeepScreenOn()
val sensorSource = container.sensorSource
@@ -141,10 +141,10 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) {
color = if (audioCueEnabled) LevelColors.LimeLockText else LevelColors.TextDim,
)
}
IconButton(onClick = onOpenSurfaceCalibration) {
IconButton(onClick = onOpenSettings) {
Icon(
Icons.Outlined.Settings,
contentDescription = "Calibration and settings",
contentDescription = "Settings",
tint = LevelColors.TextDim,
)
}
@@ -1,15 +1,21 @@
package com.onthelevel.feature.level
import androidx.compose.foundation.BorderStroke
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.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.Text
import androidx.compose.runtime.Composable
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.SurfaceCalibrationValidation
import com.onthelevel.core.sensors.TwoSampleCalibration
import com.onthelevel.core.settings.CalibrationMode
import kotlinx.coroutines.flow.map
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
* and never applies stored SurfaceCalibration to either sample.
* Surface calibration. OVERVIEW shows the current correction and lets the user start,
* 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
fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
@@ -50,12 +63,19 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
val sensorSource = container.sensorSource
val scope = rememberCoroutineScope()
var phase by remember { mutableStateOf(CalibrationPhase.FIRST_READY) }
var firstCapture by remember { mutableStateOf<CapturedSurfaceSample?>(null) }
var capture by remember { mutableStateOf<StableSurfaceCapture?>(null) }
var error by remember { mutableStateOf<String?>(null) }
val mode by container.settings.calibrationMode
.collectAsStateWithLifecycle(initialValue = CalibrationMode.SIMPLE)
val totalSteps = mode.captureCount
val existingCalibration by container.settings.surfaceCalibration
.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 pitchEma = Ema(0.15)
@@ -75,129 +95,287 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
}
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 current = reading ?: return@LaunchedEffect
val captured = activeCapture.add(current) ?: return@LaunchedEffect
when (phase) {
CalibrationPhase.FIRST_CAPTURING -> {
firstCapture = captured
val newCaptures = captures + captured
capture = null
phase = CalibrationPhase.SECOND_READY
if (newCaptures.size < totalSteps) {
captures = newCaptures
phase = CapturePhase.READY
return@LaunchedEffect
}
CalibrationPhase.SECOND_CAPTURING -> {
val first = firstCapture ?: return@LaunchedEffect
val calibration = TwoSampleCalibration.deriveSurface(
first.pitchDegrees,
first.rollDegrees,
captured.pitchDegrees,
captured.rollDegrees,
)
capture = null
val calibration = TwoSampleCalibration.deriveSurfaceFromSamples(newCaptures)
if (!SurfaceCalibrationValidation.isAcceptable(calibration)) {
error = "The placements did not agree. Keep the phone on the same spot and rotate it exactly halfway around."
phase = CalibrationPhase.ERROR
captures = emptyList()
error = "Those positions didn't agree. Keep the phone on the same spot, turn it in place, and don't flip it over."
phase = CapturePhase.ERROR
} else {
container.settings.setSurfaceCalibration(calibration)
phase = CalibrationPhase.COMPLETE
}
}
else -> Unit
captures = emptyList()
phase = CapturePhase.READY
stage = Stage.OVERVIEW
justSaved = true
scope.launch { container.settings.setSurfaceCalibration(calibration) }
}
}
val canStartCapture = reading?.let {
fun startCalibrating() {
captures = emptyList()
error = null
justSaved = false
phase = CapturePhase.READY
stage = Stage.CAPTURING
}
fun cancelCalibrating() {
// Saved calibration is never touched mid-flow, so this simply restores it.
captures = emptyList()
capture = null
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)
if (stage == Stage.CAPTURING) {
Text(
text = if (mode == CalibrationMode.THOROUGH) "THOROUGH" else "SIMPLE",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.Amber,
)
}
}
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
val status = when {
reading == null -> "Waiting for a sensor reading"
reading!!.isSettling -> "Settling—keep the phone still"
reading!!.tiltDegrees > StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES ->
"Find a flatter surface (under 5°)"
else -> "Ready to capture"
}
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text("SURFACE CALIBRATION", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
OutlinedButton(onClick = onBack) { Text("Done") }
}
Text(calibrationInstruction(phase), style = MaterialTheme.typography.headlineMedium, color = LevelColors.TextPrimary)
Text(
"This corrects your phone and case setup. It does not make this surface level.",
style = MaterialTheme.typography.bodyMedium,
color = LevelColors.TextDim,
)
Box(modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
reading?.let { formatDegrees(it.tiltDegrees) } ?: "",
style = MaterialTheme.typography.displayLarge,
color = LevelColors.TextPrimary,
)
Text(status, style = MaterialTheme.typography.bodyLarge, color = LevelColors.Amber)
}
}
when (phase) {
CalibrationPhase.FIRST_READY, CalibrationPhase.SECOND_READY -> Button(
onClick = {
capture = StableSurfaceCapture()
phase = if (phase == CalibrationPhase.FIRST_READY) CalibrationPhase.FIRST_CAPTURING else CalibrationPhase.SECOND_CAPTURING
},
enabled = canStartCapture,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (phase == CalibrationPhase.FIRST_READY) "Capture first position" else "Capture rotated position")
}
CalibrationPhase.FIRST_CAPTURING, CalibrationPhase.SECOND_CAPTURING -> Text(
"Capturing 1.5 seconds of still readings…",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = LevelColors.TextDim,
)
CalibrationPhase.COMPLETE -> {
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
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 = CalibrationPhase.FIRST_READY
phase = CapturePhase.READY
},
modifier = Modifier.fillMaxWidth(),
) { Text("Try again") }
) { Text("Start over") }
}
CalibrationOutlinedButton(text = "Cancel", onClick = ::cancelCalibrating, modifier = Modifier.fillMaxWidth())
}
}
}
}
if (existingCalibration != SurfaceCalibration.NONE && phase != CalibrationPhase.FIRST_CAPTURING && phase != CalibrationPhase.SECOND_CAPTURING) {
@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 = 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,
color = LevelColors.TextDim,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(28.dp))
Text("THOROUGHNESS", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
Spacer(Modifier.height(8.dp))
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
CalibrationMode.entries.forEachIndexed { index, entry ->
SegmentedButton(
selected = mode == entry,
onClick = { onModeChange(entry) },
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),
)
}
}
@Composable
private fun CapturingContent(
modifier: Modifier,
phase: CapturePhase,
stepIndex: Int,
totalSteps: Int,
mode: CalibrationMode,
error: String?,
tiltText: String,
status: String,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
if (phase != CapturePhase.ERROR) {
Text(
"POSITION ${stepIndex + 1} OF $totalSteps",
style = MaterialTheme.typography.labelSmall,
color = LevelColors.TextDim,
)
Spacer(Modifier.height(14.dp))
}
Text(
text = when {
phase == CapturePhase.ERROR -> error ?: "Let's try that again"
stepIndex == 0 -> "Lay the phone screen-up on a firm, roughly level surface. Keep it still."
mode == CalibrationMode.SIMPLE ->
"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 captureStatus(isSettling: Boolean?, tiltDegrees: Double?, hasReading: Boolean): String = when {
!hasReading -> "Waiting for a sensor reading"
isSettling == true -> "Settling — keep the phone still"
tiltDegrees != null && tiltDegrees > StableSurfaceCapture.MAXIMUM_CALIBRATION_TILT_DEGREES ->
"Find a flatter surface (under 5°)"
else -> "Ready to capture"
}
/** 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 = { scope.launch { container.settings.clearSurfaceCalibration() } },
modifier = Modifier.fillMaxWidth(),
) { Text("Clear saved calibration") }
}
}
onClick = onClick,
modifier = modifier,
border = BorderStroke(1.5.dp, LevelColors.Amber.copy(alpha = .65f)),
colors = ButtonDefaults.outlinedButtonColors(contentColor = LevelColors.Amber),
) { Text(text) }
}
private fun calibrationInstruction(phase: CalibrationPhase): String = when (phase) {
CalibrationPhase.FIRST_READY, CalibrationPhase.FIRST_CAPTURING ->
"Lay the phone screen-up on a firm, roughly level surface. Keep it still."
CalibrationPhase.SECOND_READY, CalibrationPhase.SECOND_CAPTURING ->
"Rotate the phone exactly 180° in the same plane. Do not flip it over or move it."
CalibrationPhase.COMPLETE -> "Calibration complete"
CalibrationPhase.ERROR -> "Try Surface calibration again"
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(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)
}
}