diff --git a/app/src/main/java/com/onthelevel/core/sensors/SurfaceGuidance.kt b/app/src/main/java/com/onthelevel/core/sensors/SurfaceGuidance.kt new file mode 100644 index 0000000..2c6e010 --- /dev/null +++ b/app/src/main/java/com/onthelevel/core/sensors/SurfaceGuidance.kt @@ -0,0 +1,44 @@ +package com.onthelevel.core.sensors + +import kotlin.math.abs +import kotlin.math.max + +/** The one source of truth for Surface bubble placement and screen-relative direction. */ +object SurfaceGuidance { + const val VISUAL_RANGE_DEGREES = 5.0 + const val DIRECTION_DEADBAND_DEGREES = 0.1 + const val CORNER_MINOR_AXIS_RATIO = 0.25 + val RING_DEGREES = listOf(1.0, 2.0, 5.0) + + data class Guidance( + val normalizedX: Double, + val normalizedY: Double, + val highLabel: String?, + ) + + fun from(pitchDegrees: Double, rollDegrees: Double): Guidance { + val x = visualPosition(rollDegrees) + val y = -visualPosition(pitchDegrees) // Compose Y grows downward; positive pitch means top is high. + return Guidance(x, y, highLabel(pitchDegrees, rollDegrees)) + } + + fun ringRadiusRatio(degrees: Double): Double = (degrees / VISUAL_RANGE_DEGREES).coerceIn(0.0, 1.0) + + private fun visualPosition(degrees: Double): Double = + (degrees / VISUAL_RANGE_DEGREES).coerceIn(-1.0, 1.0) + + private fun highLabel(pitch: Double, roll: Double): String? { + val pitchMagnitude = abs(pitch) + val rollMagnitude = abs(roll) + val dominant = max(pitchMagnitude, rollMagnitude) + if (dominant < DIRECTION_DEADBAND_DEGREES) return null + val topOrBottom = if (pitch >= 0.0) "top" else "bottom" + val leftOrRight = if (roll >= 0.0) "right" else "left" + val minor = minOf(pitchMagnitude, rollMagnitude) + return if (minor / dominant < CORNER_MINOR_AXIS_RATIO) { + if (pitchMagnitude >= rollMagnitude) "High: $topOrBottom edge" else "High: $leftOrRight edge" + } else { + "High: $topOrBottom-$leftOrRight" + } + } +} diff --git a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt index ea5bd29..1d5644a 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelPipeline.kt @@ -24,6 +24,9 @@ data class LevelReading( val secondaryADegrees: Double?, /** Surface: roll when it is meaningful. Edge: plumb lean. */ val secondaryBDegrees: Double?, + /** Stable calibrated Surface axes for the visual instrument; null outside Surface mode. */ + val stableSurfacePitchDegrees: Double? = null, + val stableSurfaceRollDegrees: Double? = null, /** Surface-only presentation state; null for Edge mode. */ val surfacePresentation: SurfacePresentation? = null, /** False when the device is not physically in the selected mode's geometry. */ @@ -93,6 +96,8 @@ class LevelPipeline( } else { null }, + stableSurfacePitchDegrees = pitch, + stableSurfaceRollDegrees = roll, surfacePresentation = presentation, placementOk = placementOk, isLocked = lock.isLocked, 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 0f0912e..3dcf003 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt @@ -70,6 +70,8 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) { .collectAsStateWithLifecycle(initialValue = EdgeCalibration.NONE) val hapticsEnabled by container.settings.hapticsEnabled .collectAsStateWithLifecycle(initialValue = true) + val reducedMotion by container.settings.reducedMotion + .collectAsStateWithLifecycle(initialValue = false) val view = LocalView.current val lockDetector = remember { LockDetector() } @@ -137,10 +139,17 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) { val placementOk = reading?.placementOk ?: true val surfacePresentation = reading?.surfacePresentation Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) { + val pitch = reading?.stableSurfacePitchDegrees + val roll = reading?.stableSurfaceRollDegrees + if (pitch != null && roll != null) { + SurfaceBullseye(pitch, roll, isLocked, reducedMotion) + } + } Text( text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—", style = MaterialTheme.typography.displayLarge, - color = if (isLocked) LevelColors.LimeLock else LevelColors.TextPrimary, + color = LevelColors.TextPrimary, ) // Lock state is announced with a label, never color alone (BRIEF.md). // The label states the tolerance (the lock's exit threshold), so the @@ -158,7 +167,11 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) { else -> "" }, style = MaterialTheme.typography.headlineMedium, - color = if (placementOk) LevelColors.LimeLockText else LevelColors.TextDim, + color = when { + !placementOk -> LevelColors.TextDim + isLocked -> LevelColors.LimeLockText + else -> LevelColors.TextPrimary + }, textAlign = TextAlign.Center, ) } diff --git a/app/src/main/java/com/onthelevel/feature/level/SurfaceBullseye.kt b/app/src/main/java/com/onthelevel/feature/level/SurfaceBullseye.kt new file mode 100644 index 0000000..4d083ba --- /dev/null +++ b/app/src/main/java/com/onthelevel/feature/level/SurfaceBullseye.kt @@ -0,0 +1,66 @@ +package com.onthelevel.feature.level + +import android.provider.Settings +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.onthelevel.core.design.LevelColors +import com.onthelevel.core.sensors.SurfaceGuidance + +/** Glass bullseye whose target and rings are both driven by SurfaceGuidance. */ +@Composable +fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean, reducedMotion: Boolean) { + val guidance = SurfaceGuidance.from(pitchDegrees, rollDegrees) + val targetX = guidance.normalizedX.toFloat() + val targetY = guidance.normalizedY.toFloat() + val animatedX = remember { Animatable(0f) } + val animatedY = remember { Animatable(0f) } + val context = LocalContext.current + val systemMotionDisabled = remember(context) { + Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f + } + val lockPulse = remember { Animatable(0f) } + LaunchedEffect(targetX, targetY, reducedMotion, systemMotionDisabled) { + if (reducedMotion || systemMotionDisabled) { + animatedX.snapTo(targetX) + animatedY.snapTo(targetY) + } else { + animatedX.animateTo(targetX, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) + animatedY.animateTo(targetY, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) + } + } + LaunchedEffect(isLocked) { + if (isLocked) { + lockPulse.snapTo(1f) + lockPulse.animateTo(0f, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow)) + } else lockPulse.snapTo(0f) + } + Canvas(Modifier.size(250.dp)) { + val radius = size.minDimension / 2f + val center = Offset(size.width / 2f, size.height / 2f) + val vialRadius = radius * .74f + drawCircle(Brush.radialGradient(listOf(Color(0xFF22272E), Color(0xFF090A0C)), center, radius), radius, center) + drawCircle(Color.White.copy(alpha = .08f), radius, center, style = Stroke(1.5f)) + SurfaceGuidance.RING_DEGREES.forEach { mark -> + drawCircle(Color.White.copy(alpha = .11f), vialRadius * SurfaceGuidance.ringRadiusRatio(mark).toFloat(), center, style = Stroke(1f)) + } + drawLine(Color.White.copy(alpha = .10f), Offset(center.x - vialRadius, center.y), Offset(center.x + vialRadius, center.y), 1f) + drawLine(Color.White.copy(alpha = .10f), Offset(center.x, center.y - vialRadius), Offset(center.x, center.y + vialRadius), 1f) + if (lockPulse.value > 0f) drawCircle(LevelColors.LimeLock.copy(alpha = .35f * lockPulse.value), vialRadius, center, style = Stroke(4f)) + val bubble = center + Offset(animatedX.value * vialRadius, animatedY.value * vialRadius) + drawCircle(Brush.radialGradient(listOf(LevelColors.AmberHighlight, LevelColors.Amber, LevelColors.AmberDeep), bubble, radius * .18f), radius * .17f, bubble) + drawCircle(Color.White.copy(alpha = .45f), radius * .045f, bubble + Offset(-radius * .05f, -radius * .05f)) + } +} diff --git a/app/src/test/java/com/onthelevel/core/sensors/SurfaceGuidanceTest.kt b/app/src/test/java/com/onthelevel/core/sensors/SurfaceGuidanceTest.kt new file mode 100644 index 0000000..6478bfd --- /dev/null +++ b/app/src/test/java/com/onthelevel/core/sensors/SurfaceGuidanceTest.kt @@ -0,0 +1,29 @@ +package com.onthelevel.core.sensors + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SurfaceGuidanceTest { + @Test fun `bubble mapping follows pitch roll and clamps before animation`() { + val guidance = SurfaceGuidance.from(2.0, -3.0) + assertEquals(-0.6, guidance.normalizedX, 1e-9) + assertEquals(-0.4, guidance.normalizedY, 1e-9) + val clamped = SurfaceGuidance.from(-8.0, 9.0) + assertEquals(1.0, clamped.normalizedX, 1e-9) + assertEquals(1.0, clamped.normalizedY, 1e-9) + } + + @Test fun `direction uses edges for dominant axes and corners otherwise`() { + assertEquals("High: top edge", SurfaceGuidance.from(2.0, 0.2).highLabel) + assertEquals("High: bottom-left", SurfaceGuidance.from(-2.0, -2.0).highLabel) + assertEquals("High: top-right", SurfaceGuidance.from(2.0, 2.0).highLabel) + assertNull(SurfaceGuidance.from(0.05, -0.05).highLabel) + } + + @Test fun `rings share the visual degree scale`() { + assertEquals(0.2, SurfaceGuidance.ringRadiusRatio(1.0), 1e-9) + assertEquals(0.4, SurfaceGuidance.ringRadiusRatio(2.0), 1e-9) + assertEquals(1.0, SurfaceGuidance.ringRadiusRatio(5.0), 1e-9) + } +}