P1 Stream C: organic design primitives (BlobShape, RadialGlow, HoneycombOverlay)
Ports iOS OrganicDesign.swift primitives to Compose Multiplatform: - BlobShape: seeded deterministic irregular shape - RadialGlow: radial-gradient backdrop composable - HoneycombOverlay: tiled hex pattern modifier - OrganicRadius constants matching iOS Determinism guaranteed by parametrized tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
package com.tt.honeyDue.ui.design
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.sqrt
|
||||
|
||||
// ============================================================================
|
||||
// Organic Design Primitives (ported from iOS OrganicDesign.swift)
|
||||
// ----------------------------------------------------------------------------
|
||||
// This module owns the minimal set of organic primitives used across screens:
|
||||
// • BlobShape — seeded, deterministic irregular Compose Shape
|
||||
// • RadialGlow — radial-gradient backdrop composable + value params
|
||||
// • HoneycombOverlay — tiled hexagonal texture modifier / composable
|
||||
// • OrganicCard — card with blob backdrop + soft shadow
|
||||
// • OrganicRadius — organic corner-radius tokens
|
||||
// • OrganicSpacing — organic spacing scale matching iOS
|
||||
// ============================================================================
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BlobShape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Irregular blob shape with [VARIATION_COUNT] canonical variations matching
|
||||
* iOS `OrganicBlobShape` (cloud / pebble / leaf). Accepts an optional [seed]
|
||||
* that perturbs the control points deterministically — the same seed always
|
||||
* produces the same geometry.
|
||||
*
|
||||
* For testability, call [serializePath] to obtain a canonical string form of
|
||||
* the emitted path operations without rendering.
|
||||
*/
|
||||
@Immutable
|
||||
class BlobShape(
|
||||
private val variation: Int = 0,
|
||||
private val seed: Long = 0L,
|
||||
) : Shape {
|
||||
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
): Outline {
|
||||
val path = Path()
|
||||
applyOps(path, buildOps(size, variation, seed))
|
||||
return Outline.Generic(path)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Number of distinct named variations — matches iOS `variation % 3`. */
|
||||
const val VARIATION_COUNT: Int = 3
|
||||
|
||||
/**
|
||||
* Canonical string serialization of the path operations for a given
|
||||
* [size], [variation] and [seed]. Used by tests (and debugging) to
|
||||
* assert deterministic output without rendering.
|
||||
*/
|
||||
fun serializePath(size: Size, variation: Int, seed: Long): String {
|
||||
val ops = buildOps(size, variation, seed)
|
||||
return buildString {
|
||||
for (op in ops) {
|
||||
append(op.toSerialized())
|
||||
append('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internals ---------------------------------------------------
|
||||
|
||||
private fun buildOps(size: Size, variation: Int, seed: Long): List<PathOp> {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val rng = Lcg(seed)
|
||||
// Jitter magnitude capped at 2% of the min dimension — keeps the
|
||||
// silhouette readable while still perturbing for a given seed.
|
||||
val jitterMax = 0.02f * minOf(w, h)
|
||||
fun jx(v: Float): Float = if (seed == 0L) v else v + rng.nextFloat(-jitterMax, jitterMax)
|
||||
fun jy(v: Float): Float = if (seed == 0L) v else v + rng.nextFloat(-jitterMax, jitterMax)
|
||||
|
||||
fun pt(fx: Float, fy: Float) = Offset(jx(fx * w), jy(fy * h))
|
||||
|
||||
val ops = mutableListOf<PathOp>()
|
||||
when (floorModInt(variation, VARIATION_COUNT)) {
|
||||
0 -> {
|
||||
// Soft cloud-like blob (iOS variation 0).
|
||||
ops += PathOp.MoveTo(pt(0.10f, 0.50f))
|
||||
ops += PathOp.CubicTo(pt(0.00f, 0.10f), pt(0.25f, 0.00f), pt(0.50f, 0.05f))
|
||||
ops += PathOp.CubicTo(pt(0.75f, 0.10f), pt(1.00f, 0.25f), pt(0.95f, 0.45f))
|
||||
ops += PathOp.CubicTo(pt(0.90f, 0.70f), pt(0.80f, 0.95f), pt(0.55f, 0.95f))
|
||||
ops += PathOp.CubicTo(pt(0.25f, 0.95f), pt(0.05f, 0.75f), pt(0.10f, 0.50f))
|
||||
}
|
||||
1 -> {
|
||||
// Pebble shape (iOS variation 1).
|
||||
ops += PathOp.MoveTo(pt(0.15f, 0.40f))
|
||||
ops += PathOp.CubicTo(pt(0.10f, 0.15f), pt(0.35f, 0.05f), pt(0.60f, 0.08f))
|
||||
ops += PathOp.CubicTo(pt(0.85f, 0.12f), pt(0.95f, 0.35f), pt(0.90f, 0.55f))
|
||||
ops += PathOp.CubicTo(pt(0.85f, 0.80f), pt(0.65f, 0.95f), pt(0.45f, 0.92f))
|
||||
ops += PathOp.CubicTo(pt(0.20f, 0.88f), pt(0.08f, 0.65f), pt(0.15f, 0.40f))
|
||||
}
|
||||
else -> {
|
||||
// Leaf-like shape (iOS variation 2+).
|
||||
ops += PathOp.MoveTo(pt(0.05f, 0.50f))
|
||||
ops += PathOp.CubicTo(pt(0.05f, 0.20f), pt(0.25f, 0.02f), pt(0.50f, 0.02f))
|
||||
ops += PathOp.CubicTo(pt(0.75f, 0.02f), pt(0.95f, 0.20f), pt(0.95f, 0.50f))
|
||||
ops += PathOp.CubicTo(pt(0.95f, 0.80f), pt(0.75f, 0.98f), pt(0.50f, 0.98f))
|
||||
ops += PathOp.CubicTo(pt(0.25f, 0.98f), pt(0.05f, 0.80f), pt(0.05f, 0.50f))
|
||||
}
|
||||
}
|
||||
ops += PathOp.Close
|
||||
return ops
|
||||
}
|
||||
|
||||
private fun applyOps(path: Path, ops: List<PathOp>) {
|
||||
for (op in ops) {
|
||||
when (op) {
|
||||
is PathOp.MoveTo -> path.moveTo(op.p.x, op.p.y)
|
||||
is PathOp.LineTo -> path.lineTo(op.p.x, op.p.y)
|
||||
is PathOp.CubicTo -> path.cubicTo(
|
||||
op.c1.x, op.c1.y,
|
||||
op.c2.x, op.c2.y,
|
||||
op.end.x, op.end.y,
|
||||
)
|
||||
PathOp.Close -> path.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Canonical op model -------------------------------------------------
|
||||
|
||||
private sealed class PathOp {
|
||||
data class MoveTo(val p: Offset) : PathOp()
|
||||
data class LineTo(val p: Offset) : PathOp()
|
||||
data class CubicTo(val c1: Offset, val c2: Offset, val end: Offset) : PathOp()
|
||||
object Close : PathOp()
|
||||
|
||||
fun toSerialized(): String = when (this) {
|
||||
is MoveTo -> "M ${fmt(p.x)},${fmt(p.y)}"
|
||||
is LineTo -> "L ${fmt(p.x)},${fmt(p.y)}"
|
||||
is CubicTo -> "C ${fmt(c1.x)},${fmt(c1.y)} ${fmt(c2.x)},${fmt(c2.y)} ${fmt(end.x)},${fmt(end.y)}"
|
||||
Close -> "Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fmt(v: Float): String {
|
||||
// Canonicalise via rounded fixed precision (4 decimal places) so two runs
|
||||
// of the same LCG serialise bit-for-bit identically.
|
||||
val scaled = kotlin.math.round(v * 10_000f) / 10_000f
|
||||
return scaled.toString()
|
||||
}
|
||||
|
||||
/** Floor-mod for positive divisor — commonMain has no `Math.floorMod`. */
|
||||
private fun floorModInt(x: Int, m: Int): Int {
|
||||
val r = x % m
|
||||
return if (r < 0) r + m else r
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal linear-congruential PRNG. Used for deterministic perturbation of
|
||||
* [BlobShape] control points. Chosen over `kotlin.random.Random(seed)` so the
|
||||
* sequence is identical across Kotlin targets (JVM / native / JS / Wasm).
|
||||
*
|
||||
* Constants are the Numerical Recipes parameters:
|
||||
* a = 1664525, c = 1013904223, m = 2^32
|
||||
*/
|
||||
private class Lcg(seed: Long) {
|
||||
private var state: Long = seed xor 0x5DEECE66DL and 0xFFFFFFFFL
|
||||
|
||||
fun nextFloat(min: Float, max: Float): Float {
|
||||
state = (1664525L * state + 1013904223L) and 0xFFFFFFFFL
|
||||
val unit = state.toFloat() / 4_294_967_296f
|
||||
return min + unit * (max - min)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RadialGlow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Value object describing a radial glow. Exposed separately so call-sites and
|
||||
* tests can reason about the parameters without touching the composable.
|
||||
*/
|
||||
@Immutable
|
||||
data class RadialGlowParams(
|
||||
val color: Color,
|
||||
val center: Offset,
|
||||
val radius: Float,
|
||||
)
|
||||
|
||||
/**
|
||||
* Paints a radial-gradient glow behind [content]. The gradient fades from
|
||||
* [color] at [center] to transparent at [radius].
|
||||
*/
|
||||
@Composable
|
||||
fun RadialGlow(
|
||||
color: Color,
|
||||
center: Offset,
|
||||
radius: Float,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable BoxScope.() -> Unit = {},
|
||||
) {
|
||||
val glowModifier = modifier.drawBehind {
|
||||
drawRect(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(color, Color.Transparent),
|
||||
center = center,
|
||||
radius = radius.coerceAtLeast(1f),
|
||||
),
|
||||
)
|
||||
}
|
||||
Box(modifier = glowModifier, content = content)
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HoneycombOverlay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Honeycomb overlay configuration — mirrors iOS `HoneycombTextureCache`
|
||||
* constants so the two platforms render the same tile.
|
||||
*/
|
||||
@Immutable
|
||||
data class HoneycombOverlayConfig(
|
||||
val tileWidth: Float = 60f,
|
||||
val tileHeight: Float = 103.92f,
|
||||
val strokeColor: Color = Color(0xFFC4856A),
|
||||
val strokeWidth: Float = 0.8f,
|
||||
val opacity: Float = 0.10f,
|
||||
)
|
||||
|
||||
/**
|
||||
* Draws a tiled honeycomb (hexagonal lattice) pattern as a background layer.
|
||||
* Drawn procedurally; Stream B may later swap this for a cached bitmap.
|
||||
*/
|
||||
@Composable
|
||||
fun HoneycombOverlay(
|
||||
modifier: Modifier = Modifier,
|
||||
config: HoneycombOverlayConfig = HoneycombOverlayConfig(),
|
||||
) {
|
||||
Canvas(modifier = modifier.fillMaxSize()) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val tW = config.tileWidth
|
||||
val tH = config.tileHeight
|
||||
val stroke = Stroke(width = config.strokeWidth)
|
||||
val color = config.strokeColor.copy(
|
||||
alpha = config.strokeColor.alpha * config.opacity
|
||||
)
|
||||
var y = 0f
|
||||
while (y < h) {
|
||||
var x = 0f
|
||||
while (x < w) {
|
||||
drawHexTile(x, y, tW, tH, color, stroke)
|
||||
x += tW
|
||||
}
|
||||
y += tH
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifier form of [HoneycombOverlay] — paints the hex lattice behind the
|
||||
* laid-out content of the composable it decorates.
|
||||
*/
|
||||
fun Modifier.honeycombOverlay(
|
||||
config: HoneycombOverlayConfig = HoneycombOverlayConfig(),
|
||||
): Modifier = this.drawBehind {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
val tW = config.tileWidth
|
||||
val tH = config.tileHeight
|
||||
val stroke = Stroke(width = config.strokeWidth)
|
||||
val color = config.strokeColor.copy(
|
||||
alpha = config.strokeColor.alpha * config.opacity
|
||||
)
|
||||
var y = 0f
|
||||
while (y < h) {
|
||||
var x = 0f
|
||||
while (x < w) {
|
||||
drawHexTile(x, y, tW, tH, color, stroke)
|
||||
x += tW
|
||||
}
|
||||
y += tH
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws two tessellating hexagons into a [tileWidth] × [tileHeight] tile,
|
||||
* matching the iOS `HoneycombTextureCache` geometry.
|
||||
*/
|
||||
private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawHexTile(
|
||||
ox: Float,
|
||||
oy: Float,
|
||||
tileWidth: Float,
|
||||
tileHeight: Float,
|
||||
color: Color,
|
||||
stroke: Stroke,
|
||||
) {
|
||||
val halfW = tileWidth / 2f
|
||||
// Side = halfW * (2 / √3); top-offset is side/2.
|
||||
val side = halfW * 2f / sqrt(3f)
|
||||
val topOffset = side / 2f
|
||||
val midOffset = topOffset + side // == 1.5 * side
|
||||
|
||||
// Hex 1 — top hexagon.
|
||||
val hex1 = Path().apply {
|
||||
moveTo(ox + halfW, oy + 0f)
|
||||
lineTo(ox + tileWidth, oy + topOffset)
|
||||
lineTo(ox + tileWidth, oy + midOffset)
|
||||
lineTo(ox + halfW, oy + midOffset + topOffset)
|
||||
lineTo(ox + 0f, oy + midOffset)
|
||||
lineTo(ox + 0f, oy + topOffset)
|
||||
close()
|
||||
}
|
||||
drawPath(hex1, color = color, style = stroke)
|
||||
|
||||
// Hex 2 — offset sibling for tessellation.
|
||||
val y2 = oy + midOffset
|
||||
val hex2 = Path().apply {
|
||||
moveTo(ox + tileWidth, y2)
|
||||
lineTo(ox + tileWidth + halfW, y2 + topOffset)
|
||||
lineTo(ox + tileWidth + halfW, y2 + midOffset)
|
||||
lineTo(ox + tileWidth, y2 + midOffset + topOffset)
|
||||
lineTo(ox + halfW, y2 + midOffset)
|
||||
lineTo(ox + halfW, y2 + topOffset)
|
||||
close()
|
||||
}
|
||||
drawPath(hex2, color = color, style = stroke)
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OrganicCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Simple organic card wrapper: organic-radius clipping, solid [background],
|
||||
* optional [blobVariation] / [blobSeed] backdrop glow.
|
||||
*
|
||||
* Colors are supplied by the caller — this module intentionally avoids
|
||||
* referencing theme tokens (Stream A owns those).
|
||||
*/
|
||||
@Composable
|
||||
fun OrganicCard(
|
||||
modifier: Modifier = Modifier,
|
||||
background: Color,
|
||||
accent: Color,
|
||||
showBlob: Boolean = true,
|
||||
blobVariation: Int = 0,
|
||||
blobSeed: Long = 0L,
|
||||
cornerRadius: Dp = OrganicRadius.blob,
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(cornerRadius))
|
||||
.background(background),
|
||||
) {
|
||||
if (showBlob) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(BlobShape(variation = blobVariation, seed = blobSeed))
|
||||
.background(
|
||||
brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
accent.copy(alpha = 0.08f),
|
||||
accent.copy(alpha = 0.02f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.TopStart,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tokens
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Organic corner-radius scale. Values match iOS `AppRadius` point values
|
||||
* (points == dp), plus a `blob` value for the signature organic card radius
|
||||
* (iOS `OrganicRoundedRectangle` cornerRadius 28).
|
||||
*/
|
||||
object OrganicRadius {
|
||||
val xs: Dp = 4.dp
|
||||
val sm: Dp = 8.dp
|
||||
val md: Dp = 12.dp
|
||||
val lg: Dp = 16.dp
|
||||
val xl: Dp = 20.dp
|
||||
|
||||
/** Signature organic card radius — matches iOS `OrganicRoundedRectangle(28)`. */
|
||||
val blob: Dp = 28.dp
|
||||
}
|
||||
|
||||
/**
|
||||
* Organic spacing scale matching iOS `OrganicSpacing`.
|
||||
*/
|
||||
object OrganicSpacing {
|
||||
val compact: Dp = 8.dp
|
||||
val cozy: Dp = 20.dp
|
||||
val comfortable: Dp = 24.dp
|
||||
val spacious: Dp = 32.dp
|
||||
val airy: Dp = 40.dp
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.tt.honeyDue.ui.design
|
||||
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for organic design primitives ported from iOS OrganicDesign.swift.
|
||||
*
|
||||
* These tests are deterministic — they verify that the same input produces
|
||||
* the same geometry so UI snapshot regressions and cross-platform parity
|
||||
* can be asserted without rendering.
|
||||
*/
|
||||
class OrganicDesignTest {
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Determinism: same seed + size → identical path
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun blobShapeIsDeterministicForSameSeedAndSize() {
|
||||
val size = Size(100f, 100f)
|
||||
val a = BlobShape.serializePath(size = size, variation = 0, seed = 42L)
|
||||
val b = BlobShape.serializePath(size = size, variation = 0, seed = 42L)
|
||||
assertEquals(a, b, "BlobShape must produce identical paths for identical inputs")
|
||||
assertTrue(a.isNotEmpty(), "Serialized path must not be empty")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blobShapeIsDeterministicAcrossMultipleInvocations() {
|
||||
val size = Size(256f, 128f)
|
||||
val runs = List(5) {
|
||||
BlobShape.serializePath(size = size, variation = 1, seed = 7L)
|
||||
}
|
||||
val first = runs.first()
|
||||
runs.forEach { assertEquals(first, it) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Variations: N distinct shapes, matching iOS OrganicBlobShape count
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun blobShapeHasExpectedVariationCountMatchingIos() {
|
||||
// iOS OrganicBlobShape switches on variation % 3 (cloud / pebble / leaf).
|
||||
assertEquals(3, BlobShape.VARIATION_COUNT)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blobShapeVariationsProduceDistinctPaths() {
|
||||
val size = Size(200f, 200f)
|
||||
val paths = (0 until BlobShape.VARIATION_COUNT).map { v ->
|
||||
BlobShape.serializePath(size = size, variation = v, seed = 0L)
|
||||
}
|
||||
// Every pair must differ.
|
||||
for (i in paths.indices) {
|
||||
for (j in i + 1 until paths.size) {
|
||||
assertNotEquals(
|
||||
paths[i],
|
||||
paths[j],
|
||||
"variation $i and $j must produce distinct paths"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blobShapeVariationIndexWrapsModulo() {
|
||||
val size = Size(100f, 100f)
|
||||
val base = BlobShape.serializePath(size = size, variation = 0, seed = 0L)
|
||||
val wrapped = BlobShape.serializePath(
|
||||
size = size,
|
||||
variation = BlobShape.VARIATION_COUNT,
|
||||
seed = 0L
|
||||
)
|
||||
assertEquals(base, wrapped, "variation index must wrap modulo VARIATION_COUNT")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blobShapeDifferentSeedsProduceDifferentPaths() {
|
||||
val size = Size(100f, 100f)
|
||||
val a = BlobShape.serializePath(size = size, variation = 0, seed = 1L)
|
||||
val b = BlobShape.serializePath(size = size, variation = 0, seed = 2L)
|
||||
assertNotEquals(a, b, "different seeds must perturb the path")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// RadialGlow: parameter API surface (not a pixel test)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun radialGlowParamsExposesColorCenterAndRadius() {
|
||||
val params = RadialGlowParams(
|
||||
color = Color.Red,
|
||||
center = Offset(10f, 20f),
|
||||
radius = 50f
|
||||
)
|
||||
assertEquals(Color.Red, params.color)
|
||||
assertEquals(Offset(10f, 20f), params.center)
|
||||
assertEquals(50f, params.radius)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun radialGlowParamsEqualityIsValueBased() {
|
||||
val a = RadialGlowParams(Color.Blue, Offset(1f, 2f), 3f)
|
||||
val b = RadialGlowParams(Color.Blue, Offset(1f, 2f), 3f)
|
||||
assertEquals(a, b)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// OrganicRadius: constants match iOS point-values
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun organicRadiusConstantsMatchIosValues() {
|
||||
// iOS AppRadius (points == dp):
|
||||
// xs 4, sm 8, md 12, lg 16, xl 20, xxl 24, full 9999
|
||||
// iOS OrganicRoundedRectangle default cornerRadius == 28 → blob.
|
||||
assertEquals(4.dp, OrganicRadius.xs)
|
||||
assertEquals(8.dp, OrganicRadius.sm)
|
||||
assertEquals(12.dp, OrganicRadius.md)
|
||||
assertEquals(16.dp, OrganicRadius.lg)
|
||||
assertEquals(20.dp, OrganicRadius.xl)
|
||||
assertEquals(28.dp, OrganicRadius.blob)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// OrganicSpacing: matches iOS OrganicSpacing (compact/cozy/…)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun organicSpacingConstantsMatchIosValues() {
|
||||
// iOS OrganicSpacing:
|
||||
// compact 8, cozy 20, comfortable 24, spacious 32, airy 40
|
||||
assertEquals(8.dp, OrganicSpacing.compact)
|
||||
assertEquals(20.dp, OrganicSpacing.cozy)
|
||||
assertEquals(24.dp, OrganicSpacing.comfortable)
|
||||
assertEquals(32.dp, OrganicSpacing.spacious)
|
||||
assertEquals(40.dp, OrganicSpacing.airy)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Honeycomb overlay: configuration exposes tile size + color + opacity
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun honeycombOverlayConfigDefaultsMatchIos() {
|
||||
// iOS HoneycombTextureCache: tile 60 x 103.92, stroke #C4856A, 0.8pt.
|
||||
val cfg = HoneycombOverlayConfig()
|
||||
assertEquals(60f, cfg.tileWidth)
|
||||
assertEquals(103.92f, cfg.tileHeight)
|
||||
// #C4856A opaque — iOS honeycomb stroke color.
|
||||
assertEquals(Color(0xFFC4856A), cfg.strokeColor)
|
||||
assertEquals(0.8f, cfg.strokeWidth)
|
||||
assertEquals(0.10f, cfg.opacity)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user