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.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user