Surface polish: motion fix, instrument redesign, readout typography (Codex + Claude)
Driven by on-device iteration on a Galaxy S24+ (live ADB install/ screenshot loop), gated by a real-device motion pass at animator scale 1x and frame stats (p95 8ms, 0.85% legacy jank). Motion (Codex): X/Y axes animate in independent concurrent effects. The prior sequential animateTo calls starved Y under ~50Hz retargeting - diagonal movement traced an L and the second axis caught up seconds late. Reduced-motion/system-disabled paths snap both axes. Instrument (Claude, on Codex's base): - Fluorescent yellow-green fluid (matches real spirit-level dye); amber retained for guidance text. - Bubble as a true void: borderless symmetric gradient orb, translucent interior (marks read through it), soft bright refraction band, contact shadow, fluorescent halo, window-style reflection + glint. Velocity-squash experiment tried and removed - shape fidelity beats the flourish. - Bubble (0.12R) nests inside the 1-degree target ring; ring turns lime on VISUAL containment of the drawn bubble (lock label/pulse/ haptic remain on LockDetector truth). - Machined bezel, etched cardinal/45 ticks, per-ring degree labels via the same SurfaceGuidance mapping. dp-scaled strokes replace raw-pixel hairlines; contrast lifted for real screens. - Red center target dot beneath the translucent bubble (Codex). Text hierarchy: guidance suppressed while locked (no correction advice under a 'Flat within' verdict), demoted to titleLarge, and raised into the empty status line's slot. Primary readout optically dead-centered via an invisible leading degree twin; degree symbol .55em at TextDim with cap-height baseline shift (.38 - metrics pair, resize together). 59 tests passing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user