diff --git a/app/src/main/java/com/onthelevel/MainActivity.kt b/app/src/main/java/com/onthelevel/MainActivity.kt index e44b15f..fef4aee 100644 --- a/app/src/main/java/com/onthelevel/MainActivity.kt +++ b/app/src/main/java/com/onthelevel/MainActivity.kt @@ -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") { diff --git a/app/src/main/java/com/onthelevel/OnTheLevelApp.kt b/app/src/main/java/com/onthelevel/OnTheLevelApp.kt index e07d62d..038ea21 100644 --- a/app/src/main/java/com/onthelevel/OnTheLevelApp.kt +++ b/app/src/main/java/com/onthelevel/OnTheLevelApp.kt @@ -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() } diff --git a/app/src/main/java/com/onthelevel/core/sensors/SurfaceCalibrationValidation.kt b/app/src/main/java/com/onthelevel/core/sensors/SurfaceCalibrationValidation.kt index 1cb0b57..7e9924f 100644 --- a/app/src/main/java/com/onthelevel/core/sensors/SurfaceCalibrationValidation.kt +++ b/app/src/main/java/com/onthelevel/core/sensors/SurfaceCalibrationValidation.kt @@ -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): 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 + } } 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 32eaed4..4652c9d 100644 --- a/app/src/main/java/com/onthelevel/feature/level/SurfaceCalibrationScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/level/SurfaceCalibrationScreen.kt @@ -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, diff --git a/app/src/main/java/com/onthelevel/feature/tools/SettingsScreen.kt b/app/src/main/java/com/onthelevel/feature/tools/SettingsScreen.kt index a848444..38168f0 100644 --- a/app/src/main/java/com/onthelevel/feature/tools/SettingsScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/tools/SettingsScreen.kt @@ -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) } }, ) } } diff --git a/app/src/main/java/com/onthelevel/feature/tools/ToolsScreen.kt b/app/src/main/java/com/onthelevel/feature/tools/ToolsScreen.kt index d99bc01..a9f9397 100644 --- a/app/src/main/java/com/onthelevel/feature/tools/ToolsScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/tools/ToolsScreen.kt @@ -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, ) } } diff --git a/app/src/test/java/com/onthelevel/core/sensors/SurfaceCalibrationValidationTest.kt b/app/src/test/java/com/onthelevel/core/sensors/SurfaceCalibrationValidationTest.kt index 1071255..d9f6714 100644 --- a/app/src/test/java/com/onthelevel/core/sensors/SurfaceCalibrationValidationTest.kt +++ b/app/src/test/java/com/onthelevel/core/sensors/SurfaceCalibrationValidationTest.kt @@ -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)) + } }