From 85a8ce11367e6204d8189ce0028bf8e2553c849c Mon Sep 17 00:00:00 2001 From: Jay Date: Tue, 14 Jul 2026 10:37:35 -0400 Subject: [PATCH] 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 --- .../main/java/com/onthelevel/MainActivity.kt | 12 +- .../core/sensors/TwoSampleCalibration.kt | 16 + .../core/settings/CalibrationMode.kt | 11 + .../core/settings/SettingsRepository.kt | 12 + .../onthelevel/feature/level/LevelScreen.kt | 6 +- .../feature/level/SurfaceCalibrationScreen.kt | 396 +++++++++++++----- .../feature/tools/SettingsScreen.kt | 155 +++++++ .../core/sensors/TwoSampleCalibrationTest.kt | 29 ++ 8 files changed, 523 insertions(+), 114 deletions(-) create mode 100644 app/src/main/java/com/onthelevel/core/settings/CalibrationMode.kt create mode 100644 app/src/main/java/com/onthelevel/feature/tools/SettingsScreen.kt diff --git a/app/src/main/java/com/onthelevel/MainActivity.kt b/app/src/main/java/com/onthelevel/MainActivity.kt index 59e9bc4..e44b15f 100644 --- a/app/src/main/java/com/onthelevel/MainActivity.kt +++ b/app/src/main/java/com/onthelevel/MainActivity.kt @@ -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() }) } diff --git a/app/src/main/java/com/onthelevel/core/sensors/TwoSampleCalibration.kt b/app/src/main/java/com/onthelevel/core/sensors/TwoSampleCalibration.kt index 7d7578e..950bfc4 100644 --- a/app/src/main/java/com/onthelevel/core/sensors/TwoSampleCalibration.kt +++ b/app/src/main/java/com/onthelevel/core/sensors/TwoSampleCalibration.kt @@ -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): 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, diff --git a/app/src/main/java/com/onthelevel/core/settings/CalibrationMode.kt b/app/src/main/java/com/onthelevel/core/settings/CalibrationMode.kt new file mode 100644 index 0000000..f7c8ace --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/settings/CalibrationMode.kt @@ -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), +} diff --git a/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt b/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt index 2f5b42b..68405ff 100644 --- a/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt +++ b/app/src/main/java/com/onthelevel/core/settings/SettingsRepository.kt @@ -51,6 +51,13 @@ class SettingsRepository(context: Context) { } } + val calibrationMode: Flow = 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") } } diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt index 86d29a3..ae4339e 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt @@ -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, ) } diff --git a/app/src/main/java/com/onthelevel/feature/level/SurfaceCalibrationScreen.kt b/app/src/main/java/com/onthelevel/feature/level/SurfaceCalibrationScreen.kt index 0fe5834..32eaed4 100644 --- a/app/src/main/java/com/onthelevel/feature/level/SurfaceCalibrationScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/level/SurfaceCalibrationScreen.kt @@ -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(null) } - var capture by remember { mutableStateOf(null) } - var error by remember { mutableStateOf(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>(emptyList()) } + var capture by remember { mutableStateOf(null) } + var error by remember { mutableStateOf(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 - capture = null - phase = CalibrationPhase.SECOND_READY - } - CalibrationPhase.SECOND_CAPTURING -> { - val first = firstCapture ?: return@LaunchedEffect - val calibration = TwoSampleCalibration.deriveSurface( - first.pitchDegrees, - first.rollDegrees, - captured.pitchDegrees, - captured.rollDegrees, - ) - capture = null - 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 - } else { - container.settings.setSurfaceCalibration(calibration) - phase = CalibrationPhase.COMPLETE - } - } - else -> Unit + + val newCaptures = captures + captured + capture = null + if (newCaptures.size < totalSteps) { + captures = newCaptures + phase = CapturePhase.READY + return@LaunchedEffect + } + val calibration = TwoSampleCalibration.deriveSurfaceFromSamples(newCaptures) + if (!SurfaceCalibrationValidation.isAcceptable(calibration)) { + 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 { + captures = emptyList() + phase = CapturePhase.READY + stage = Stage.OVERVIEW + justSaved = true + scope.launch { container.settings.setSurfaceCalibration(calibration) } } } - val canStartCapture = 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" + fun startCalibrating() { + captures = emptyList() + error = null + justSaved = false + phase = CapturePhase.READY + stage = Stage.CAPTURING } - Column( - modifier = Modifier.fillMaxSize().padding(horizontal = 24.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(18.dp), - ) { - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + 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) - 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( - "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, color = LevelColors.TextDim, + textAlign = TextAlign.Center, ) - - 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) + 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), + ) + } +} - 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, +@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, ) - 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") } - } + Spacer(Modifier.height(14.dp)) } - - if (existingCalibration != SurfaceCalibration.NONE && phase != CalibrationPhase.FIRST_CAPTURING && phase != CalibrationPhase.SECOND_CAPTURING) { - OutlinedButton( - onClick = { scope.launch { container.settings.clearSurfaceCalibration() } }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Clear saved calibration") } + 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 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 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 = 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) + } } diff --git a/app/src/main/java/com/onthelevel/feature/tools/SettingsScreen.kt b/app/src/main/java/com/onthelevel/feature/tools/SettingsScreen.kt new file mode 100644 index 0000000..a848444 --- /dev/null +++ b/app/src/main/java/com/onthelevel/feature/tools/SettingsScreen.kt @@ -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, + ), + ) + } +} diff --git a/app/src/test/java/com/onthelevel/core/sensors/TwoSampleCalibrationTest.kt b/app/src/test/java/com/onthelevel/core/sensors/TwoSampleCalibrationTest.kt index 3a23b88..a6baac9 100644 --- a/app/src/test/java/com/onthelevel/core/sensors/TwoSampleCalibrationTest.kt +++ b/app/src/test/java/com/onthelevel/core/sensors/TwoSampleCalibrationTest.kt @@ -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) + } }