diff --git a/app/src/main/java/com/onthelevel/core/design/Theme.kt b/app/src/main/java/com/onthelevel/core/design/Theme.kt index 352356f..405808e 100644 --- a/app/src/main/java/com/onthelevel/core/design/Theme.kt +++ b/app/src/main/java/com/onthelevel/core/design/Theme.kt @@ -13,7 +13,8 @@ import androidx.compose.ui.unit.sp /** * Palette from the concept board (resources/Bubble Level Concepts.dc.html): - * deep graphite glass, warm amber spirit fluid, lime for the locked state. + * deep graphite glass, warm amber guidance, fluorescent spirit fluid, and lime + * for the locked state. * The design is dark-only in v1 — this is an instrument, not a document. * Per BRIEF.md, color never carries state alone; labels and numbers always accompany it. */ @@ -27,6 +28,16 @@ object LevelColors { val AmberHighlight = Color(0xFFFFEEB0) val AmberDeep = Color(0xFFE0961E) + // Fluorescent yellow-green is both familiar from physical spirit levels and + // substantially more legible than amber against the graphite vial. + val VialLime = Color(0xFFD9FF38) + val VialHighlight = Color(0xFFF7FFD5) + val VialDeep = Color(0xFF86B800) + val VialTarget = Color(0xFFFF6868) + + /** Stronger neutral surface reserved for live numeric instrument panels. */ + val ReadoutPanel = Color(0xFF242931) + val LimeLock = Color(0xFFCBEF5C) val LimeLockText = Color(0xFFEAFFC2) 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 cf496dd..86d29a3 100644 --- a/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt +++ b/app/src/main/java/com/onthelevel/feature/level/LevelScreen.kt @@ -11,6 +11,7 @@ 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Settings @@ -33,8 +34,15 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.BaselineShift import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.em import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.onthelevel.AppContainer @@ -163,7 +171,10 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) { ) { val placementOk = reading?.placementOk ?: true val surfacePresentation = reading?.surfacePresentation - Column(horizontalAlignment = Alignment.CenterHorizontally) { + Column( + modifier = Modifier.offset(y = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { if (mode == LevelMode.SURFACE && surfacePresentation == SurfacePresentation.FACE_UP) { val pitch = reading?.stableSurfacePitchDegrees val roll = reading?.stableSurfaceRollDegrees @@ -172,7 +183,8 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) { } } Text( - text = reading?.let { formatDegrees(it.displayPrimaryDegrees) } ?: "—", + text = reading?.let { primaryReadout(formatDegrees(it.displayPrimaryDegrees)) } + ?: AnnotatedString("—"), style = MaterialTheme.typography.displayLarge, color = LevelColors.TextPrimary, ) @@ -210,7 +222,14 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) { .height(56.dp), contentAlignment = Alignment.TopCenter, ) { - SurfaceAdjustmentInfo(reading, measurementUnits) + // Guidance and the status label are mutually exclusive + // (guidance renders only while the label line is empty), + // so it rises to visually take the label's place. + SurfaceAdjustmentInfo( + reading, + measurementUnits, + Modifier.offset(y = (-36).dp), + ) } } } @@ -242,10 +261,18 @@ fun LevelScreen(container: AppContainer, onOpenSurfaceCalibration: () -> Unit) { * strong instruction while the phone is still being placed. */ @Composable -private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnits) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { +private fun SurfaceAdjustmentInfo( + reading: LevelReading?, + units: MeasurementUnits, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { if (reading == null) return@Column + // A locked surface is flat within tolerance: issuing correction guidance + // under the lock line would contradict it and crowd the status stack. + if (reading.isLocked) return@Column + if (reading.isSettling) { Text( text = "Settling", @@ -259,9 +286,11 @@ private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnit val roll = reading.secondaryBDegrees ?: return@Column val highLabel = SurfaceGuidance.from(pitch, roll).highLabel ?: return@Column + // One rank below the lock/status line (28sp mono): guidance advises, it + // doesn't compete. Text( text = highLabel, - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.titleLarge, color = LevelColors.Amber, textAlign = TextAlign.Center, ) @@ -277,15 +306,26 @@ private fun SurfaceAdjustmentInfo(reading: LevelReading?, units: MeasurementUnit private fun ValuePanel(label: String, value: Double?, modifier: Modifier = Modifier) { Surface( modifier = modifier, - color = LevelColors.Panel, + color = LevelColors.ReadoutPanel, contentColor = LevelColors.TextPrimary, shape = MaterialTheme.shapes.large, ) { - Column(modifier = Modifier.padding(16.dp)) { - Text(label, style = MaterialTheme.typography.labelSmall, color = LevelColors.TextDim) + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + label, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelSmall, + color = LevelColors.TextDim, + textAlign = TextAlign.Center, + ) Text( text = value?.let { formatDegrees(it) } ?: "—", + modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, ) } } @@ -315,6 +355,31 @@ private fun statusLine(isCalibrated: Boolean, kind: SensorSource.Kind): String { internal fun formatDegrees(value: Double): String = String.format(Locale.US, "%.1f°", value) +/** + * Primary readout typography: the degree symbol rendered small and faint keeps the + * number elegant without losing the instrument voice — and its invisible leading + * twin balances the trailing one, so the digits sit optically dead-center no + * matter the glyph widths. + */ +private fun primaryReadout(text: String): AnnotatedString = buildAnnotatedString { + // The shrunken symbol would otherwise sit on the digits' baseline at + // mid-height; the baseline shift floats it back up to the cap line where a + // degree mark belongs. + val degreeStyle = SpanStyle( + fontSize = DEGREE_SYMBOL_EM.em, + baselineShift = BaselineShift(DEGREE_SYMBOL_SHIFT), + ) + withStyle(degreeStyle.copy(color = Color.Transparent)) { append("°") } + append(text.removeSuffix("°")) + withStyle(degreeStyle.copy(color = LevelColors.TextDim)) { append("°") } +} + +private const val DEGREE_SYMBOL_EM = .55f +// No cap-height-alignment primitive exists in Compose; this value is derived from +// Roboto's metrics (digit cap height minus the degree glyph's top at the symbol +// size above — resize them together), verified against on-device captures. +private const val DEGREE_SYMBOL_SHIFT = .38f + internal fun formatRiseRun(valueDegrees: Double, units: MeasurementUnits): String = when (units) { MeasurementUnits.METRIC -> String.format(Locale.US, "%.1f mm/m", RiseRun.millimetersPerMeter(valueDegrees)) diff --git a/app/src/main/java/com/onthelevel/feature/level/SurfaceBullseye.kt b/app/src/main/java/com/onthelevel/feature/level/SurfaceBullseye.kt index 4d083ba..eb8b919 100644 --- a/app/src/main/java/com/onthelevel/feature/level/SurfaceBullseye.kt +++ b/app/src/main/java/com/onthelevel/feature/level/SurfaceBullseye.kt @@ -13,13 +13,34 @@ 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.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.withTransform import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.onthelevel.core.design.LevelColors +import com.onthelevel.core.design.ReadoutFontFamily import com.onthelevel.core.sensors.SurfaceGuidance +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.sin -/** Glass bullseye whose target and rings are both driven by SurfaceGuidance. */ +/** + * Glass bullseye whose target and rings are both driven by SurfaceGuidance. + * + * The bubble is a VOID in fluorescent fluid: a perfectly round, borderless + * translucent orb — one symmetric gradient whose edge fades out rather than + * outlining — grounded by a contact shadow and lit by a small fixed upper-left + * reflection. The calibration marks (and the red target dot) stay readable + * through it. Every layer serves depth or readability (SURFACE_LEVEL_PLAN.md: + * no decorative gloss); alphas are the tuning knobs if anything reads as chrome. + */ @Composable fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean, reducedMotion: Boolean) { val guidance = SurfaceGuidance.from(pitchDegrees, rollDegrees) @@ -32,12 +53,21 @@ fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f } val lockPulse = remember { Animatable(0f) } - LaunchedEffect(targetX, targetY, reducedMotion, systemMotionDisabled) { + val textMeasurer = rememberTextMeasurer() + // These must run independently. animateTo suspends until a spring settles; + // sequencing axes here would starve Y under the sensor's ~50 Hz retargeting + // and make diagonal movement trace an unnatural L. + LaunchedEffect(targetX, reducedMotion, systemMotionDisabled) { if (reducedMotion || systemMotionDisabled) { animatedX.snapTo(targetX) - animatedY.snapTo(targetY) } else { animatedX.animateTo(targetX, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) + } + } + LaunchedEffect(targetY, reducedMotion, systemMotionDisabled) { + if (reducedMotion || systemMotionDisabled) { + animatedY.snapTo(targetY) + } else { animatedY.animateTo(targetY, spring(dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessMediumLow)) } } @@ -51,16 +81,222 @@ fun SurfaceBullseye(pitchDegrees: Double, rollDegrees: Double, isLocked: Boolean 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)) + + val bubbleCenter = center + Offset(animatedX.value * vialRadius, animatedY.value * vialRadius) + // Nested inside the 1° target ring (0.148 × radius): the level-read is the + // classic "bubble inside the circle", never the bubble swallowing the mark. + val bubbleRadius = radius * BUBBLE_RADIUS_RATIO + + // The target ring highlights on what the EYE sees: the drawn bubble sitting + // inside (or tangent to) the circle. Lock truth — label, pulse, haptic — + // stays with LockDetector; this accent must never outlive the visual. + val targetRingRadius = vialRadius * SurfaceGuidance.ringRadiusRatio(1.0).toFloat() + val bubbleInsideTarget = + hypot(bubbleCenter.x - center.x, bubbleCenter.y - center.y) + bubbleRadius <= + targetRingRadius + 1.dp.toPx() + + drawVialGlass(center, radius, vialRadius) + drawCalibrationMarks(center, radius, vialRadius, bubbleInsideTarget, textMeasurer) + if (lockPulse.value > 0f) { + drawCircle(LevelColors.LimeLock.copy(alpha = .55f * lockPulse.value), vialRadius, center, style = Stroke(3.dp.toPx())) } - 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)) + + drawBubbleBody(bubbleCenter, bubbleRadius) + drawBubbleLight(bubbleCenter, bubbleRadius) } } + +private fun DrawScope.drawVialGlass(center: Offset, radius: Float, vialRadius: Float) { + drawCircle(Brush.radialGradient(listOf(Color(0xFF22272E), Color(0xFF090A0C)), center, radius), radius, center) + // Faint sheen from the fixed upper-left light — a depth cue, kept far below gloss. + drawCircle( + Brush.radialGradient( + listOf(Color.White.copy(alpha = .05f), Color.Transparent), + center + Offset(-radius * .38f, -radius * .42f), + radius * .9f, + ), + radius, + center, + ) + // Fluid vignette: the dye reads darker where it meets the vial wall. + drawCircle( + Brush.radialGradient( + colorStops = arrayOf( + 0f to Color.Transparent, + .78f to Color.Transparent, + 1f to Color.Black.copy(alpha = .32f), + ), + center = center, + radius = vialRadius, + ), + vialRadius, + center, + ) + // Machined bezel: a shallow metallic annulus, lit from above, instead of a + // flat outline — housing depth without adding information noise. + val bezelWidth = 5.dp.toPx() + drawCircle( + Brush.linearGradient( + listOf(Color.White.copy(alpha = .20f), Color.White.copy(alpha = .05f)), + start = Offset(center.x, center.y - radius), + end = Offset(center.x, center.y + radius), + ), + radius - bezelWidth / 2f, + center, + style = Stroke(bezelWidth), + ) + drawCircle(Color.White.copy(alpha = .14f), radius - bezelWidth, center, style = Stroke(1.dp.toPx())) + // Etched bezel ticks: cardinals strong, 45° minors faint — orientation + // vocabulary borrowed from real instrument bezels. + for (degrees in 0 until 360 step 45) { + val isCardinal = degrees % 90 == 0 + val angle = Math.toRadians(degrees.toDouble()) + val direction = Offset(cos(angle).toFloat(), sin(angle).toFloat()) + val outer = radius - 1.dp.toPx() + val inner = outer - if (isCardinal) 8.dp.toPx() else 5.dp.toPx() + drawLine( + Color.White.copy(alpha = if (isCardinal) .45f else .22f), + center + direction * inner, + center + direction * outer, + if (isCardinal) 1.5.dp.toPx() else 1.dp.toPx(), + ) + } +} + +private fun DrawScope.drawCalibrationMarks( + center: Offset, + radius: Float, + vialRadius: Float, + bubbleInsideTarget: Boolean, + textMeasurer: TextMeasurer, +) { + val labelStyle = TextStyle( + fontSize = 9.sp, + fontFamily = ReadoutFontFamily, + color = Color.White.copy(alpha = .38f), + ) + SurfaceGuidance.RING_DEGREES.forEach { mark -> + val isTargetRing = mark == 1.0 + val ringRadius = vialRadius * SurfaceGuidance.ringRadiusRatio(mark).toFloat() + val color = if (isTargetRing && bubbleInsideTarget) { + // Restrained lime accent, driven by visual containment of the drawn + // bubble. The label still carries the lock state in words. + LevelColors.LimeLock.copy(alpha = .9f) + } else { + val alpha = when (mark) { + 1.0 -> .46f // The target zone needs to be the clearest calibration mark. + 2.0 -> .36f + else -> .27f + } + Color.White.copy(alpha = alpha) + } + drawCircle( + color, + ringRadius, + center, + style = Stroke(if (isTargetRing && bubbleInsideTarget) 2.dp.toPx() else 1.5.dp.toPx()), + ) + // Each ring says what it means: its degree value, etched small on the + // lower-right diagonal just outside the ring. + val layout = textMeasurer.measure(AnnotatedString("${mark.toInt()}°"), labelStyle) + val diagonal = (ringRadius + 5.dp.toPx()) * DIAGONAL_COMPONENT + val position = center + Offset(diagonal, diagonal) + drawText( + layout, + topLeft = position - Offset(layout.size.width / 2f, layout.size.height / 2f), + ) + } + val crosshairStroke = 1.dp.toPx() + drawLine(Color.White.copy(alpha = .32f), Offset(center.x - vialRadius, center.y), Offset(center.x + vialRadius, center.y), crosshairStroke) + drawLine(Color.White.copy(alpha = .32f), Offset(center.x, center.y - vialRadius), Offset(center.x, center.y + vialRadius), crosshairStroke) + // The exact-center target mark stays beneath the translucent bubble, seen + // through the trapped air just as it would be in a real vial. + drawCircle(Color.White.copy(alpha = .85f), radius * .030f, center) + drawCircle(LevelColors.VialTarget, radius * .020f, center) +} + +/** A borderless, perfectly round translucent orb: one symmetric gradient, edge fading to nothing. */ +private fun DrawScope.drawBubbleBody(c: Offset, b: Float) { + // Fluorescent halo: the dye glows brightest where the lens bends light past the rim. + drawCircle( + Brush.radialGradient( + listOf(LevelColors.VialLime.copy(alpha = .16f), Color.Transparent), + c, + b * 1.9f, + ), + b * 1.9f, + c, + ) + // Contact shadow: a soft dark ring just outside the rim seats the bubble IN the + // fluid instead of floating over the graphics. + drawCircle( + Brush.radialGradient( + colorStops = arrayOf( + 0f to Color.Transparent, + .68f to Color.Transparent, + .80f to Color.Black.copy(alpha = .30f), + 1f to Color.Transparent, + ), + center = c, + radius = b * 1.3f, + ), + b * 1.3f, + c, + ) + // The orb itself. Centered gradient = perfectly round; the soft bright band + // sits evenly inside the rim, and the thin dark contact edge fades out rather + // than drawing a border. + drawCircle( + Brush.radialGradient( + colorStops = arrayOf( + 0f to LevelColors.VialHighlight.copy(alpha = .24f), + .52f to LevelColors.VialLime.copy(alpha = .18f), + .80f to LevelColors.VialLime.copy(alpha = .36f), + .91f to LevelColors.VialHighlight.copy(alpha = .66f), + .965f to LevelColors.VialDeep.copy(alpha = .32f), + 1f to Color.Transparent, + ), + center = c, + radius = b * 1.06f, + ), + b * 1.06f, + c, + ) +} + +/** + * Reflection layer, world-oriented: a soft window-style reflection patch at the + * upper-left (how glass and soap bubbles actually mirror a light source), a sharp + * glint at its heart, and a faint pass-through shimmer on the far rim. + */ +private fun DrawScope.drawBubbleLight(c: Offset, b: Float) { + withTransform({ + translate(c.x - b * .30f, c.y - b * .32f) + rotate(-38f, Offset.Zero) + scale(1f, .55f, Offset.Zero) + }) { + drawCircle( + Brush.radialGradient( + listOf(Color.White.copy(alpha = .50f), Color.Transparent), + Offset.Zero, + b * .46f, + ), + b * .46f, + Offset.Zero, + ) + } + drawCircle(Color.White.copy(alpha = .85f), b * .07f, c + Offset(-b * .30f, -b * .34f)) + val shimmerCenter = c + Offset(b * .42f, b * .44f) + drawCircle( + Brush.radialGradient( + listOf(Color.White.copy(alpha = .12f), Color.Transparent), + shimmerCenter, + b * .30f, + ), + b * .30f, + shimmerCenter, + ) +} + +private const val BUBBLE_RADIUS_RATIO = .12f +private const val DIAGONAL_COMPONENT = .7071f diff --git a/resources/Surface Level.dc.html b/resources/Surface Level.dc.html new file mode 100644 index 0000000..4689e66 --- /dev/null +++ b/resources/Surface Level.dc.html @@ -0,0 +1,252 @@ + + +
+ + + + + +