Address Codex audit: Tools cards, 4-point validation, durable persistence
P1 - Tools screen no longer exposes dead 'coming with the feature build'
cards. Replaced the stale Calibration and Units/Feedback cards with a
single working Settings card that opens the Settings screen.
P1 - Detailed (4-point) calibration now validates rotation quality, not
just the final mean. A 4-point set holds two independent 180-flip bias
estimates - the (0,180) and (90,270) pairs; a malformed turn makes them
diverge even when the mean stays small (which the 3-degree magnitude
guard can't see). isRotationConsistent rejects divergence beyond 0.4
degrees. The disagreement scales with the surface's true tilt, so it's
lenient near level and strict on a steeper permitted surface. Test
proves a deliberately 10-degree-over-rotated set is rejected and a clean
set passes.
P2 - Calibration persistence moved from the screen's rememberCoroutineScope
to a container-owned applicationScope, so the write survives an immediate
Done/back after 'saved'. Preference writes moved there too.
Cleanup: haptic copy is mode-neutral ('when the level locks'); Settings
no longer keeps the screen awake (calibration still does); calibration
method renamed Thoroughness -> Method with Standard/Detailed labels and
accurate 'Four positions, a quarter-turn apart' / 'Averages more sensor
noise' copy; lime now marks the active-calibration status (confirmed
state), consistent with lime = level/confirmed.
64 tests passing; assembleDebug clean; verified on device.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -96,7 +96,10 @@ private fun AppRoot(container: AppContainer) {
|
||||
}
|
||||
composable("angle") { AngleScreen(container) }
|
||||
composable("tools") {
|
||||
ToolsScreen(onOpenRuler = { navController.navigate("ruler") })
|
||||
ToolsScreen(
|
||||
onOpenRuler = { navController.navigate("ruler") },
|
||||
onOpenSettings = { navController.navigate("settings") },
|
||||
)
|
||||
}
|
||||
composable("ruler") { RulerScreen(container) }
|
||||
composable("settings") {
|
||||
|
||||
@@ -7,6 +7,9 @@ import com.onthelevel.core.billing.StubProEntitlementRepository
|
||||
import com.onthelevel.core.sensors.AndroidSensorSource
|
||||
import com.onthelevel.core.sensors.SensorSource
|
||||
import com.onthelevel.core.settings.SettingsRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
|
||||
/**
|
||||
* Manual application container — deliberately no DI framework for an app this size
|
||||
@@ -15,6 +18,13 @@ import com.onthelevel.core.settings.SettingsRepository
|
||||
class AppContainer(context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
/**
|
||||
* Outlives any single screen. Used for durable fire-and-forget persistence
|
||||
* (e.g. saving a calibration): a screen-scoped coroutine would be cancelled if
|
||||
* the user leaves the moment the write is launched.
|
||||
*/
|
||||
val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
val sensorSource: SensorSource by lazy { AndroidSensorSource(appContext) }
|
||||
val settings: SettingsRepository by lazy { SettingsRepository(appContext) }
|
||||
val entitlement: ProEntitlementRepository by lazy { StubProEntitlementRepository() }
|
||||
|
||||
@@ -3,12 +3,34 @@ package com.onthelevel.core.sensors
|
||||
import kotlin.math.hypot
|
||||
|
||||
/**
|
||||
* Guards calibration persistence against an inconsistent two-sample turn. The bound
|
||||
* applies to the combined two-axis correction, not each axis independently.
|
||||
* Guards calibration persistence against a bad calibration.
|
||||
*/
|
||||
object SurfaceCalibrationValidation {
|
||||
const val MAXIMUM_BIAS_MAGNITUDE_DEGREES = 3.0
|
||||
const val PAIR_AGREEMENT_TOLERANCE_DEGREES = 0.4
|
||||
|
||||
/** The combined two-axis correction must be small; a large mean means something moved. */
|
||||
fun isAcceptable(calibration: SurfaceCalibration): Boolean =
|
||||
hypot(calibration.pitchBiasDegrees, calibration.rollBiasDegrees) <= MAXIMUM_BIAS_MAGNITUDE_DEGREES
|
||||
|
||||
/**
|
||||
* A 4-point set (0°/90°/180°/270°) contains TWO independent 180°-flip bias
|
||||
* estimates: the (0,180) pair and the (90,270) pair. A clean turn makes them
|
||||
* agree; a malformed rotation makes them diverge even when the overall mean stays
|
||||
* plausibly small — which the magnitude guard alone cannot detect. Reject when the
|
||||
* two estimates disagree beyond tolerance.
|
||||
*
|
||||
* The disagreement scales with the surface's true tilt, so this is naturally
|
||||
* lenient near level (where a turn error barely matters) and strict on a steeper
|
||||
* permitted surface (where it matters most). Sets that aren't the 4-point shape
|
||||
* carry no cross-check and pass here; the magnitude guard still applies.
|
||||
*/
|
||||
fun isRotationConsistent(samples: List<CapturedSurfaceSample>): Boolean {
|
||||
if (samples.size != 4) return true
|
||||
val pitchA = (samples[0].pitchDegrees + samples[2].pitchDegrees) / 2.0
|
||||
val rollA = (samples[0].rollDegrees + samples[2].rollDegrees) / 2.0
|
||||
val pitchB = (samples[1].pitchDegrees + samples[3].pitchDegrees) / 2.0
|
||||
val rollB = (samples[1].rollDegrees + samples[3].rollDegrees) / 2.0
|
||||
return hypot(pitchA - pitchB, rollA - rollB) <= PAIR_AGREEMENT_TOLERANCE_DEGREES
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -62,7 +61,6 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
|
||||
KeepScreenOn()
|
||||
|
||||
val sensorSource = container.sensorSource
|
||||
val scope = rememberCoroutineScope()
|
||||
val mode by container.settings.calibrationMode
|
||||
.collectAsStateWithLifecycle(initialValue = CalibrationMode.SIMPLE)
|
||||
val totalSteps = mode.captureCount
|
||||
@@ -112,7 +110,8 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val calibration = TwoSampleCalibration.deriveSurfaceFromSamples(newCaptures)
|
||||
if (!SurfaceCalibrationValidation.isAcceptable(calibration)) {
|
||||
val consistent = SurfaceCalibrationValidation.isRotationConsistent(newCaptures)
|
||||
if (!SurfaceCalibrationValidation.isAcceptable(calibration) || !consistent) {
|
||||
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
|
||||
@@ -121,7 +120,8 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
|
||||
phase = CapturePhase.READY
|
||||
stage = Stage.OVERVIEW
|
||||
justSaved = true
|
||||
scope.launch { container.settings.setSurfaceCalibration(calibration) }
|
||||
// App-owned scope: the write must survive an immediate Done/back.
|
||||
container.applicationScope.launch { container.settings.setSurfaceCalibration(calibration) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
|
||||
Text("SURFACE CALIBRATION", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
||||
if (stage == Stage.CAPTURING) {
|
||||
Text(
|
||||
text = if (mode == CalibrationMode.THOROUGH) "THOROUGH" else "SIMPLE",
|
||||
text = if (mode == CalibrationMode.THOROUGH) "DETAILED" else "STANDARD",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LevelColors.Amber,
|
||||
)
|
||||
@@ -165,7 +165,7 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
|
||||
calibration = existingCalibration,
|
||||
justSaved = justSaved,
|
||||
mode = mode,
|
||||
onModeChange = { scope.launch { container.settings.setCalibrationMode(it) } },
|
||||
onModeChange = { container.applicationScope.launch { container.settings.setCalibrationMode(it) } },
|
||||
)
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = ::startCalibrating, modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -176,7 +176,7 @@ fun SurfaceCalibrationScreen(container: AppContainer, onBack: () -> Unit) {
|
||||
text = "Reset to phone defaults",
|
||||
onClick = {
|
||||
justSaved = false
|
||||
scope.launch { container.settings.clearSurfaceCalibration() }
|
||||
container.applicationScope.launch { container.settings.clearSurfaceCalibration() }
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
@@ -250,7 +250,12 @@ private fun OverviewContent(
|
||||
Text(
|
||||
text = if (justSaved) "CALIBRATION SAVED" else "CURRENT CORRECTION",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (justSaved) LevelColors.LimeLockText else LevelColors.TextDim,
|
||||
// Lime marks the confirmed/active state: just-saved, or an active calibration.
|
||||
color = when {
|
||||
justSaved -> LevelColors.LimeLockText
|
||||
isCalibrated -> LevelColors.LimeLock
|
||||
else -> LevelColors.TextDim
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
if (isCalibrated) {
|
||||
@@ -280,7 +285,7 @@ private fun OverviewContent(
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(Modifier.height(28.dp))
|
||||
Text("THOROUGHNESS", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
||||
Text("METHOD", style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
CalibrationMode.entries.forEachIndexed { index, entry ->
|
||||
@@ -289,15 +294,15 @@ private fun OverviewContent(
|
||||
onClick = { onModeChange(entry) },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, CalibrationMode.entries.size),
|
||||
) {
|
||||
Text(if (entry == CalibrationMode.SIMPLE) "Simple · 2 pt" else "Thorough · 4 pt")
|
||||
Text(if (entry == CalibrationMode.SIMPLE) "Standard" else "Detailed")
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = if (mode == CalibrationMode.THOROUGH) {
|
||||
"Four quarter-turns. Slower, but averages out more error."
|
||||
"Four positions, a quarter-turn apart. Averages more sensor noise and cross-checks the turn."
|
||||
} else {
|
||||
"Two positions, half a turn apart. Quick and dependable."
|
||||
"Two positions, a half-turn apart. Quick and dependable."
|
||||
},
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = LevelColors.TextDim,
|
||||
|
||||
@@ -23,13 +23,11 @@ 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
|
||||
@@ -45,9 +43,8 @@ fun SettingsScreen(
|
||||
onOpenSurfaceCalibration: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
KeepScreenOn()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// A preferences list should time out normally — no KeepScreenOn here (calibration
|
||||
// and the live tool screens keep the screen awake themselves).
|
||||
val units by container.settings.measurementUnits
|
||||
.collectAsStateWithLifecycle(initialValue = MeasurementUnits.METRIC)
|
||||
val reducedMotion by container.settings.reducedMotion
|
||||
@@ -78,7 +75,7 @@ fun SettingsScreen(
|
||||
MeasurementUnits.entries.forEachIndexed { index, entry ->
|
||||
SegmentedButton(
|
||||
selected = units == entry,
|
||||
onClick = { scope.launch { container.settings.setMeasurementUnits(entry) } },
|
||||
onClick = { container.applicationScope.launch { container.settings.setMeasurementUnits(entry) } },
|
||||
shape = SegmentedButtonDefaults.itemShape(index, MeasurementUnits.entries.size),
|
||||
) {
|
||||
Text(if (entry == MeasurementUnits.METRIC) "Metric" else "Imperial")
|
||||
@@ -90,16 +87,16 @@ fun SettingsScreen(
|
||||
SettingsSection("Feedback & motion") {
|
||||
ToggleRow(
|
||||
label = "Haptic feedback",
|
||||
description = "A gentle buzz when a surface locks level.",
|
||||
description = "A gentle buzz when the level locks.",
|
||||
checked = hapticsEnabled,
|
||||
onCheckedChange = { scope.launch { container.settings.setHapticsEnabled(it) } },
|
||||
onCheckedChange = { container.applicationScope.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) } },
|
||||
onCheckedChange = { container.applicationScope.launch { container.settings.setReducedMotion(it) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.onthelevel.core.design.LevelColors
|
||||
|
||||
@Composable
|
||||
fun ToolsScreen(onOpenRuler: () -> Unit) {
|
||||
fun ToolsScreen(onOpenRuler: () -> Unit, onOpenSettings: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -33,14 +33,9 @@ fun ToolsScreen(onOpenRuler: () -> Unit) {
|
||||
onClick = onOpenRuler,
|
||||
)
|
||||
ToolCard(
|
||||
title = "Calibration",
|
||||
subtitle = "Two-sample level calibration — coming with the feature build",
|
||||
onClick = { /* TODO(feature): guided calibration flow */ },
|
||||
)
|
||||
ToolCard(
|
||||
title = "Units & Feedback",
|
||||
subtitle = "Haptics, audio cue, reduced motion — coming with the feature build",
|
||||
onClick = { /* TODO(feature): preferences UI over SettingsRepository */ },
|
||||
title = "Settings",
|
||||
subtitle = "Calibration, units, feedback, and motion",
|
||||
onClick = onOpenSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.onthelevel.core.sensors
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
class SurfaceCalibrationValidationTest {
|
||||
|
||||
@@ -17,4 +19,37 @@ class SurfaceCalibrationValidationTest {
|
||||
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(3.01, 0.0)))
|
||||
assertFalse(SurfaceCalibrationValidation.isAcceptable(SurfaceCalibration(2.5, 2.5)))
|
||||
}
|
||||
|
||||
// A 5° surface is the worst permitted case (bias magnitude ≤ 5° is allowed to reach
|
||||
// the mean). biasPitch/Roll are the fixed device bias; the true tilt rotates with
|
||||
// the phone about the surface normal.
|
||||
private val biasPitch = 0.3
|
||||
private val biasRoll = -0.2
|
||||
private val tilt = 5.0
|
||||
|
||||
private fun sampleAt(rotationDegrees: Double) = CapturedSurfaceSample(
|
||||
pitchDegrees = biasPitch + tilt * cos(Math.toRadians(rotationDegrees)),
|
||||
rollDegrees = biasRoll + tilt * sin(Math.toRadians(rotationDegrees)),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `clean four-point turn is consistent`() {
|
||||
val clean = listOf(sampleAt(0.0), sampleAt(90.0), sampleAt(180.0), sampleAt(270.0))
|
||||
assertTrue(SurfaceCalibrationValidation.isRotationConsistent(clean))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sloppy four-point turn is rejected`() {
|
||||
// Second position over-rotated 10° (100° instead of 90°). The (90,270) pair no
|
||||
// longer cancels, so the two independent bias estimates diverge past tolerance —
|
||||
// a malformed rotation the 3° magnitude guard alone cannot catch.
|
||||
val sloppy = listOf(sampleAt(0.0), sampleAt(100.0), sampleAt(180.0), sampleAt(270.0))
|
||||
assertFalse(SurfaceCalibrationValidation.isRotationConsistent(sloppy))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two-point set has no cross-check and is always consistent`() {
|
||||
val twoPoint = listOf(CapturedSurfaceSample(0.8, -0.1), CapturedSurfaceSample(-0.2, 0.5))
|
||||
assertTrue(SurfaceCalibrationValidation.isRotationConsistent(twoPoint))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user