diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/design/OrganicDesign.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/design/OrganicDesign.kt new file mode 100644 index 0000000..e63696b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/ui/design/OrganicDesign.kt @@ -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 { + 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() + 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) { + 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 +} diff --git a/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/design/OrganicDesignTest.kt b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/design/OrganicDesignTest.kt new file mode 100644 index 0000000..0f84b3e --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/tt/honeyDue/ui/design/OrganicDesignTest.kt @@ -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) + } +}