diff --git a/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt new file mode 100644 index 0000000..a1c488e --- /dev/null +++ b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/CycleMarkers.kt @@ -0,0 +1,101 @@ +package dev.privacyllc.period.designsystem.art + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * The four calendar states, drawn so they differ in **shape**. + * + * PRODUCT_PLAN.md §26 and §43 both require it and for the same reason: a + * calendar whose states differ only in colour carries no information for a + * colourblind user, none in a greyscale screenshot, and none in the bug report + * somebody files about it. + * + * confirmed ● solid disc + * predicted ◌ dotted outline + * fertile ○ continuous ring + * ovulation ✦ small four-pointed mark + * + * Colour still carries meaning — it is just never the only thing that does. + * + * Each one is a named composable rather than a drawable resource because that is + * what "behind a replaceable name" means in Compose: swapping the drawing is a + * change in one function body, and every call site follows. + */ + +/** A period the user confirmed. Solid, because it is the only state that is certain. */ +@Composable +fun ConfirmedPeriodMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier: Modifier = Modifier) { + Canvas(modifier.size(size)) { drawCircle(color = color, radius = this.size.minDimension / 2f) } +} + +/** + * A period the app predicts. + * + * Dotted, and never a lighter version of the confirmed marker: §26 is explicit + * that predicted and confirmed must never look identical, and a difference in + * opacity alone fails exactly when the screen is dim or the user is not looking + * closely. + */ +@Composable +fun PredictedPeriodMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier: Modifier = Modifier) { + Canvas(modifier.size(size)) { + val stroke = this.size.minDimension * MarkerDefaults.StrokeFraction + drawCircle( + color = color, + radius = (this.size.minDimension - stroke) / 2f, + style = Stroke( + width = stroke, + pathEffect = PathEffect.dashPathEffect( + floatArrayOf(this.size.minDimension / 9f, this.size.minDimension / 9f), + ), + ), + ) + } +} + +/** The estimated fertile window. A continuous ring — open, unlike the solid confirmed disc. */ +@Composable +fun FertileWindowMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier: Modifier = Modifier) { + Canvas(modifier.size(size)) { + val stroke = this.size.minDimension * MarkerDefaults.StrokeFraction + drawCircle(color = color, radius = (this.size.minDimension - stroke) / 2f, style = Stroke(width = stroke)) + } +} + +/** Estimated ovulation. Its own shape entirely, because it is a day rather than a span. */ +@Composable +fun OvulationMarker(color: Color, size: Dp = MarkerDefaults.Size, modifier: Modifier = Modifier) { + Canvas(modifier.size(size)) { drawFourPointStar(color) } +} + +internal fun DrawScope.drawFourPointStar(color: Color) { + val r = size.minDimension / 2f + val c = Offset(size.width / 2f, size.height / 2f) + val waist = r * 0.34f + val path = Path().apply { + moveTo(c.x, c.y - r) + quadraticTo(c.x + waist, c.y - waist, c.x + r, c.y) + quadraticTo(c.x + waist, c.y + waist, c.x, c.y + r) + quadraticTo(c.x - waist, c.y + waist, c.x - r, c.y) + quadraticTo(c.x - waist, c.y - waist, c.x, c.y - r) + close() + } + drawPath(path, color) +} + +object MarkerDefaults { + val Size: Dp = 32.dp + /** Thick enough to read at 32dp on a dense screen, thin enough to stay a ring rather than a disc. */ + const val StrokeFraction = 0.14f +} diff --git a/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/Illustrations.kt b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/Illustrations.kt new file mode 100644 index 0000000..07d238b --- /dev/null +++ b/core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/Illustrations.kt @@ -0,0 +1,221 @@ +package dev.privacyllc.period.designsystem.art + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin + +/** + * Placeholder illustrations, exactly as PRODUCT_PLAN.md §42 asks for them. + * + * > If custom artwork cannot be produced immediately, create polished + * > placeholder vector assets and keep them behind replaceable resource names. + * + * Every one is drawn, not drawn *on* — vector paths, no raster, so they stay + * crisp at any density and add nothing to the APK. + * + * ## What none of them may be + * + * §42's forbidden list, and it is a product decision rather than squeamishness: + * no blood drops, tampons, pads, uterus imagery, gender symbols or anatomical + * graphics. This app is opened in public. Someone glancing over a shoulder + * should learn nothing, and that is a property of the pictures as much as of the + * notification text. + * + * So the whole visual language here is **overlapping circular forms** — a cycle, + * abstractly. It is also the language of the launcher icon, which is what makes + * a placeholder set look deliberate rather than improvised. + * + * ## Replacing them + * + * These are the replaceable names. When real artwork arrives, the drawing inside + * each function changes and no call site does. + */ + +/** Screen 1 of onboarding. Overlapping rings — the cycle, before it means anything specific. */ +@Composable +fun WelcomeIllustration( + primary: Color, + accent: Color, + size: Dp = IllustrationDefaults.Size, + modifier: Modifier = Modifier, +) { + Canvas( + modifier + .size(size) + .semantics { contentDescription = "Three overlapping circles" }, + ) { + val r = this.size.minDimension * 0.28f + val c = center + val stroke = Stroke(width = this.size.minDimension * 0.035f) + drawCircle(accent.copy(alpha = 0.55f), r, Offset(c.x - r * 0.55f, c.y - r * 0.2f), style = stroke) + drawCircle(accent.copy(alpha = 0.75f), r, Offset(c.x + r * 0.55f, c.y - r * 0.2f), style = stroke) + drawCircle(primary, r, Offset(c.x, c.y + r * 0.45f), style = stroke) + } +} + +/** + * The empty state — no periods logged yet. + * + * A ring with a single gap, because the story is *the cycle has not started + * being recorded yet*, not *something is broken*. Empty states are one of the + * two screens most people meet first and the one most often left undesigned. + */ +@Composable +fun EmptyStateIllustration( + color: Color, + size: Dp = IllustrationDefaults.Size, + modifier: Modifier = Modifier, +) { + Canvas( + modifier + .size(size) + .semantics { contentDescription = "An empty circle" }, + ) { + val d = this.size.minDimension * 0.72f + val stroke = this.size.minDimension * 0.035f + drawArc( + color = color, + startAngle = -60f, + sweepAngle = 300f, + useCenter = false, + topLeft = Offset(center.x - d / 2f, center.y - d / 2f), + size = Size(d, d), + style = Stroke( + width = stroke, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(d / 22f, d / 14f)), + ), + ) + } +} + +/** + * The learning state — a cycle or two recorded, not yet a pattern. + * + * §42: points gradually converging into a pattern. Scattered dots that tighten + * toward a ring as they go round, which is the honest picture of what the engine + * is doing and reads as progress rather than as an error. + */ +@Composable +fun LearningIllustration( + color: Color, + accent: Color, + size: Dp = IllustrationDefaults.Size, + modifier: Modifier = Modifier, +) { + Canvas( + modifier + .size(size) + .semantics { contentDescription = "Scattered dots settling into a circle" }, + ) { + val radius = this.size.minDimension * 0.32f + val dot = this.size.minDimension * 0.028f + val count = 12 + repeat(count) { i -> + val progress = i / (count - 1f) + val angle = (progress * 1.6f * PI - PI / 2).toFloat() + // Early points sit off the ring and late ones settle onto it. + val scatter = (1f - progress) * radius * 0.42f + val wobble = if (i % 2 == 0) scatter else -scatter + val r = radius + wobble + drawCircle( + color = if (progress > 0.66f) color else accent.copy(alpha = 0.35f + progress * 0.5f), + radius = dot * (0.7f + progress * 0.6f), + center = Offset(center.x + r * cos(angle), center.y + r * sin(angle)), + ) + } + } +} + +/** + * The privacy promise screen. + * + * A shield with a circular cycle mark inside it — §42 asks for a minimal + * shield or lock motif, and putting the cycle inside the shield is the whole + * sentence the screen is making. + */ +@Composable +fun PrivacyIllustration( + color: Color, + accent: Color, + size: Dp = IllustrationDefaults.Size, + modifier: Modifier = Modifier, +) { + Canvas( + modifier + .size(size) + .semantics { contentDescription = "A shield containing a circle" }, + ) { + val w = this.size.minDimension * 0.52f + val h = this.size.minDimension * 0.62f + val c = center + val shield = Path().apply { + moveTo(c.x, c.y - h / 2f) + lineTo(c.x + w / 2f, c.y - h / 2f + h * 0.16f) + cubicTo( + c.x + w / 2f, c.y + h * 0.16f, + c.x + w * 0.28f, c.y + h * 0.42f, + c.x, c.y + h / 2f, + ) + cubicTo( + c.x - w * 0.28f, c.y + h * 0.42f, + c.x - w / 2f, c.y + h * 0.16f, + c.x - w / 2f, c.y - h / 2f + h * 0.16f, + ) + close() + } + drawPath(shield, color, style = Stroke(width = this.size.minDimension * 0.035f)) + drawCircle(accent, radius = w * 0.20f, center = Offset(c.x, c.y - h * 0.02f), style = Stroke(width = this.size.minDimension * 0.03f)) + drawCircle(accent, radius = w * 0.05f, center = Offset(c.x, c.y - h * 0.02f - w * 0.20f)) + } +} + +/** + * The forecast mark used beside the hero number on Today. + * + * A ring with one offset dot — the same idea as the launcher icon, so the app's + * own mark appears inside it rather than only on the home screen. + */ +@Composable +fun CycleProgressMark( + color: Color, + accent: Color, + /** 0f at the start of the cycle, 1f at the predicted start of the next. */ + progress: Float, + size: Dp = 56.dp, + modifier: Modifier = Modifier, +) { + Canvas(modifier.size(size)) { + val r = this.size.minDimension * 0.38f + val stroke = this.size.minDimension * 0.07f + drawCircle(color.copy(alpha = 0.25f), r, center, style = Stroke(width = stroke)) + drawArc( + color = color, + startAngle = -90f, + sweepAngle = 360f * progress.coerceIn(0f, 1f), + useCenter = false, + topLeft = Offset(center.x - r, center.y - r), + size = Size(r * 2, r * 2), + style = Stroke(width = stroke), + ) + val angle = (-PI / 2 + 2 * PI * progress.coerceIn(0f, 1f)).toFloat() + drawCircle(accent, stroke * 0.85f, Offset(center.x + r * cos(angle), center.y + r * sin(angle))) + } +} + +object IllustrationDefaults { + val Size: Dp = 160.dp +} diff --git a/docs/design/README.md b/docs/design/README.md index 0f496ff..4cbd28f 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -91,6 +91,27 @@ Designed here on purpose, because they are the two most people meet first: rather than showing a confident forecast it has not earned. "Getting to know your pattern", not a percentage. +## The artwork is placeholder, and says so here + +Every illustration and calendar marker in the app is a **Compose vector path** +in `core/designsystem/.../art/`, written as PRODUCT_PLAN.md §42 asks: polished +placeholders behind replaceable names, so real artwork changes one function body +and no call site. + +The visual language is deliberately narrow — **overlapping circular forms** and +nothing else. §42's forbidden list (blood drops, tampons, pads, uterus imagery, +gender symbols, anatomy) is a product decision rather than squeamishness: this +app gets opened in public, and somebody glancing over a shoulder should learn +nothing. That is a property of the pictures as much as of the notification text. + +**Note the deliberate inconsistency with `docs/data/img/`,** where a placeholder +is forbidden. The rule there is that an image which looks finished outlives the +issue that would have replaced it, because nobody files a ticket against +something that appears done. In-app assets escape that because §42 asks for them +explicitly and the replaceable-name mechanism is what keeps them replaceable — +and because a screen with no illustration cannot be evaluated at all, while a +project card with no icon simply shows initials. + ## Include the rejected version For any decision that was genuinely close, record what was not chosen and why.