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:
@@ -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 ->
|
||||
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 = 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"
|
||||
}
|
||||
|
||||
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
|
||||
error = null
|
||||
phase = CalibrationPhase.FIRST_READY
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text("Try again") }
|
||||
}
|
||||
}
|
||||
|
||||
if (existingCalibration != SurfaceCalibration.NONE && phase != CalibrationPhase.FIRST_CAPTURING && phase != CalibrationPhase.SECOND_CAPTURING) {
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user