diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt index 6d900f7..fa9991b 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/insights/InsightsScreen.kt @@ -101,11 +101,7 @@ private fun LearningBanner(stage: LearningStage, cycleCount: Int) { horizontalAlignment = Alignment.CenterHorizontally, ) { if (stage != LearningStage.PERSONALIZED) { - LearningIllustration( - color = MaterialTheme.colorScheme.primary, - accent = MaterialTheme.colorScheme.tertiary, - size = 110.dp, - ) + LearningIllustration(size = 110.dp) Spacer(Modifier.height(12.dp)) } Text(title, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingPreviews.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingPreviews.kt index b359cd8..5938a39 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingPreviews.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingPreviews.kt @@ -1,22 +1,26 @@ package dev.privacyllc.period.feature.onboarding import android.content.res.Configuration -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import dev.privacyllc.period.designsystem.PeriodTheme -import dev.privacyllc.period.designsystem.art.EmptyStateIllustration -import dev.privacyllc.period.designsystem.art.LearningIllustration -import dev.privacyllc.period.designsystem.art.PrivacyIllustration -import dev.privacyllc.period.designsystem.art.WelcomeIllustration import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import dev.privacyllc.period.designsystem.PeriodTheme +import dev.privacyllc.period.designsystem.art.EmptyStateIllustration +import dev.privacyllc.period.designsystem.art.ForecastIllustration +import dev.privacyllc.period.designsystem.art.LastPeriodIllustration +import dev.privacyllc.period.designsystem.art.LearningIllustration +import dev.privacyllc.period.designsystem.art.NotificationPrivacyIllustration +import dev.privacyllc.period.designsystem.art.PeriodEndIllustration +import dev.privacyllc.period.designsystem.art.PrivacyIllustration +import dev.privacyllc.period.designsystem.art.WelcomeIllustration /** * Every illustration, in both themes. @@ -31,12 +35,19 @@ import androidx.compose.ui.unit.dp * A preview pair is not a test and does not fail a build. What it does is put * both themes in front of whoever is editing the screen, which is the cheapest * thing that would have caught it. + * + * These now carry a second job. The illustrations are drawables with a + * `drawable-night/` twin, so the theme swap happens in resource resolution + * rather than in any code here — and a missing or mismatched night asset is + * invisible until somebody looks at the dark preview. There is one entry per + * illustration for exactly that reason: a set of eight where seven have a night + * variant looks completely fine in light mode. */ -@Preview(name = "Illustrations · light", showBackground = true, heightDp = 620) +@Preview(name = "Illustrations · light", showBackground = true, heightDp = 1100) @Preview( name = "Illustrations · dark", showBackground = true, - heightDp = 620, + heightDp = 1100, uiMode = Configuration.UI_MODE_NIGHT_YES, ) @Composable @@ -47,26 +58,26 @@ private fun IllustrationPreviews() { verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - Text("Welcome", style = MaterialTheme.typography.labelLarge) - WelcomeIllustration( - primary = MaterialTheme.colorScheme.primary, - accent = MaterialTheme.colorScheme.tertiary, - size = 120.dp, - ) - Text("Learning", style = MaterialTheme.typography.labelLarge) - LearningIllustration( - color = MaterialTheme.colorScheme.primary, - accent = MaterialTheme.colorScheme.tertiary, - size = 110.dp, - ) - Text("Privacy", style = MaterialTheme.typography.labelLarge) - PrivacyIllustration( - color = MaterialTheme.colorScheme.primary, - accent = MaterialTheme.colorScheme.tertiary, - size = 110.dp, - ) - Text("Empty state", style = MaterialTheme.typography.labelLarge) - EmptyStateIllustration(color = MaterialTheme.colorScheme.outline, size = 110.dp) + Label("1 · Welcome") + WelcomeIllustration(size = 110.dp) + Label("2 · Last period start") + LastPeriodIllustration(size = 110.dp) + Label("3 · Period end") + PeriodEndIllustration(size = 110.dp) + Label("4 · Learning, and Insights empty") + LearningIllustration(size = 110.dp) + Label("5 · Privacy promise") + PrivacyIllustration(size = 110.dp) + Label("6 · Reminder privacy") + NotificationPrivacyIllustration(size = 110.dp) + Label("7 · First forecast") + ForecastIllustration(size = 110.dp) + Label("Today empty state") + EmptyStateIllustration(size = 110.dp) } } } + +@Composable +private fun Label(text: String) = + Text(text, style = MaterialTheme.typography.labelLarge) diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt index a3ea06d..d816ea2 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt @@ -36,7 +36,11 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.privacyllc.period.core.datastore.NotificationPrivacy +import dev.privacyllc.period.designsystem.art.ForecastIllustration +import dev.privacyllc.period.designsystem.art.LastPeriodIllustration import dev.privacyllc.period.designsystem.art.LearningIllustration +import dev.privacyllc.period.designsystem.art.NotificationPrivacyIllustration +import dev.privacyllc.period.designsystem.art.PeriodEndIllustration import dev.privacyllc.period.designsystem.art.PrivacyIllustration import dev.privacyllc.period.designsystem.art.WelcomeIllustration import java.time.Instant @@ -120,10 +124,7 @@ private fun Heading(title: String, body: String? = null) { @Composable private fun Welcome(onNext: () -> Unit) { Spacer(Modifier.height(32.dp)) - WelcomeIllustration( - primary = MaterialTheme.colorScheme.primary, - accent = MaterialTheme.colorScheme.tertiary, - ) + WelcomeIllustration() Spacer(Modifier.height(32.dp)) Heading("Know what's coming.", "Track your period and get predictions that learn your cycle.") Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { Text("Get Started") } @@ -132,7 +133,9 @@ private fun Welcome(onNext: () -> Unit) { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun LastPeriod(state: OnboardingUiState, viewModel: OnboardingViewModel) { - Spacer(Modifier.height(32.dp)) + Spacer(Modifier.height(24.dp)) + LastPeriodIllustration(size = 128.dp) + Spacer(Modifier.height(24.dp)) Heading("When did your last period start?") DateField( @@ -152,7 +155,11 @@ private fun LastPeriod(state: OnboardingUiState, viewModel: OnboardingViewModel) @Composable private fun PeriodEnd(state: OnboardingUiState, viewModel: OnboardingViewModel) { - Spacer(Modifier.height(32.dp)) + Spacer(Modifier.height(16.dp)) + // Smaller than the other steps: this one carries three buttons under the + // date field, and the primary must stay reachable without scrolling. + PeriodEndIllustration(size = 104.dp) + Spacer(Modifier.height(16.dp)) Heading("When did it end?") var picked by remember { mutableStateOf(null) } @@ -181,11 +188,7 @@ private fun PeriodEnd(state: OnboardingUiState, viewModel: OnboardingViewModel) @Composable private fun PreviousHistory(state: OnboardingUiState, viewModel: OnboardingViewModel) { Spacer(Modifier.height(24.dp)) - LearningIllustration( - color = MaterialTheme.colorScheme.primary, - accent = MaterialTheme.colorScheme.tertiary, - size = 120.dp, - ) + LearningIllustration(size = 120.dp) Spacer(Modifier.height(24.dp)) Heading( "Remember any earlier periods?", @@ -220,11 +223,7 @@ private fun PreviousHistory(state: OnboardingUiState, viewModel: OnboardingViewM @Composable private fun PrivacyPromise(onNext: () -> Unit) { Spacer(Modifier.height(24.dp)) - PrivacyIllustration( - color = MaterialTheme.colorScheme.primary, - accent = MaterialTheme.colorScheme.tertiary, - size = 140.dp, - ) + PrivacyIllustration(size = 140.dp) Spacer(Modifier.height(24.dp)) // §4 requires this promise here, in Settings, and on the public privacy // page. The wording is deliberate: we never SELL your data. It does not @@ -242,7 +241,10 @@ private fun PrivacyPromise(onNext: () -> Unit) { @Composable private fun NotificationPrivacyStep(state: OnboardingUiState, viewModel: OnboardingViewModel) { - Spacer(Modifier.height(32.dp)) + Spacer(Modifier.height(16.dp)) + // Also small: three option cards and a button follow it. + NotificationPrivacyIllustration(size = 104.dp) + Spacer(Modifier.height(16.dp)) Heading("How should reminders appear?") Column(Modifier.selectableGroup().fillMaxWidth()) { @@ -298,7 +300,9 @@ private fun PrivacyOption(title: String, example: String, selected: Boolean, onS @Composable private fun FirstForecast(state: OnboardingUiState, onDone: () -> Unit) { - Spacer(Modifier.height(32.dp)) + Spacer(Modifier.height(24.dp)) + ForecastIllustration(size = 120.dp) + Spacer(Modifier.height(24.dp)) Heading("Your first forecast") val forecast = state.forecast diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt index 24202d7..54fdf84 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt @@ -312,7 +312,7 @@ private fun PrimaryAction(text: String, onClick: () -> Unit) { @Composable private fun EmptyState() { Spacer(Modifier.height(24.dp)) - EmptyStateIllustration(color = MaterialTheme.colorScheme.outline, size = 140.dp) + EmptyStateIllustration(size = 140.dp) Spacer(Modifier.height(24.dp)) Text("No periods logged yet", style = MaterialTheme.typography.headlineSmall) Spacer(Modifier.height(8.dp)) 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 index 8ff33e2..7739187 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -12,6 +13,9 @@ 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 +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin /** * The four calendar states, drawn so they differ in **shape**. @@ -110,6 +114,43 @@ internal fun DrawScope.drawFourPointStar(color: Color) { drawPath(path, color) } +/** + * The forecast mark 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. + * + * This lives with the markers rather than with the illustrations because it is + * **drawn from data**: the arc is the user's actual position in their cycle, so + * it has to be a vector no matter how good the artwork elsewhere gets. + */ +@Composable +fun CycleProgressMark( + color: Color, + accent: Color, + /** 0f at the start of the cycle, 1f at the predicted start of the next. */ + progress: Float, + modifier: Modifier = Modifier, + size: Dp = 56.dp, +) { + 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 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. */ 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 index 4289cef..c00e956 100644 --- 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 @@ -1,221 +1,174 @@ package dev.privacyllc.period.designsystem.art -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.size +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape 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.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.sin +import dev.privacyllc.period.designsystem.R /** - * Placeholder illustrations, exactly as PRODUCT_PLAN.md §42 asks for them. + * The onboarding and empty-state illustrations. * - * > If custom artwork cannot be produced immediately, create polished - * > placeholder vector assets and keep them behind replaceable resource names. + * ## Why these are raster, when PRODUCT_PLAN.md §42 asks for vectors * - * Every one is drawn, not drawn *on* — vector paths, no raster, so they stay - * crisp at any density and add nothing to the APK. + * §42's preference list ends *"avoid **unnecessary** raster imagery"*, and it + * offers polished placeholder vectors as the fallback for artwork that does not + * exist yet — which is what used to be here. The artwork now exists: eight + * illustrations supplied by the project owner, in a matched light and dark pair. * - * ## What none of them may be + * They are gradient landscapes with soft glow and depth. There is no honest + * `VectorDrawable` of one; converting them would either lose the drawing or + * produce a path soup larger and slower than the image. So the raster is + * necessary rather than unnecessary, and the whole set costs 216 KB. * - * §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. + * [CycleProgressMark] stays a vector, because it is the one mark that is + * *drawn from data* — its arc is the user's actual position in their cycle. * - * 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. + * ## How the theme swap works * - * ## Replacing them + * There is no `isSystemInDarkTheme()` branch here on purpose. Each drawable has + * a `drawable/` and a `drawable-night/` copy, and Android resolves it. That + * means the dark art also follows a device-level theme change with no + * recomposition of ours, and a screenshot test of either theme picks the right + * one for free. * - * These are the replaceable names. When real artwork arrives, the drawing inside - * each function changes and no call site does. + * ## Sources + * + * `docs/design/dist/` holds the delivered set at full resolution, with a README + * recording the decisions behind them — no baked text, no step numbers, and a + * forecast illustration that deliberately names no fields. Re-read it before + * regenerating any of these. */ -/** Screen 1 of onboarding. Overlapping rings — the cycle, before it means anything specific. */ +/** Step 1. Overlapping rings over a dawn landscape — the cycle, before it means anything specific. */ @Composable fun WelcomeIllustration( - primary: Color, - accent: Color, modifier: Modifier = Modifier, size: Dp = IllustrationDefaults.Size, -) { - 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) - } -} +) = Illustration(R.drawable.art_welcome, "Overlapping circles above a sunrise", modifier, size) + +/** Step 2. A cycle ring with one marked day — the first day being recorded. */ +@Composable +fun LastPeriodIllustration( + modifier: Modifier = Modifier, + size: Dp = IllustrationDefaults.Size, +) = Illustration(R.drawable.art_last_period, "A cycle ring with one marked day", modifier, size) + +/** Step 3. The same ring with a span between two marked days — a period with a start and an end. */ +@Composable +fun PeriodEndIllustration( + modifier: Modifier = Modifier, + size: Dp = IllustrationDefaults.Size, +) = Illustration(R.drawable.art_period_end, "A cycle ring with a marked span between two days", modifier, size) + +/** + * Step 4, and the Insights empty state. + * + * Earlier cycles fading behind the current one — §42's "points gradually + * converging into a pattern", which is the honest picture of what the engine + * does with history. Used in two places, which is why it carries no step number. + */ +@Composable +fun LearningIllustration( + modifier: Modifier = Modifier, + size: Dp = IllustrationDefaults.Size, +) = Illustration(R.drawable.art_learning, "Earlier cycle rings fading behind the current one", modifier, size) + +/** + * Step 5, the privacy promise. + * + * A shield with the cycle ring inside it. Putting the cycle *inside* the shield + * is the whole sentence the screen is making, and enclosure reads as protection + * without reaching for a padlock — which would make a calm product look like a + * security one. + */ +@Composable +fun PrivacyIllustration( + modifier: Modifier = Modifier, + size: Dp = IllustrationDefaults.Size, +) = Illustration(R.drawable.art_privacy, "A shield containing a cycle ring", modifier, size) + +/** Step 6. A notification with a shield on it — what a reminder shows, and what it withholds. */ +@Composable +fun NotificationPrivacyIllustration( + modifier: Modifier = Modifier, + size: Dp = IllustrationDefaults.Size, +) = Illustration(R.drawable.art_notification_privacy, "A notification card marked with a shield", modifier, size) + +/** + * Step 7, the first forecast. + * + * A calendar and a cycle arc running from logged days into dashed predicted + * ones. It deliberately names no fields: a drawing that said "fertile window" + * or "confidence: low" would contradict the live forecast beside it, which on a + * first run declines to estimate fertility at all. + */ +@Composable +fun ForecastIllustration( + modifier: Modifier = Modifier, + size: Dp = IllustrationDefaults.Size, +) = Illustration(R.drawable.art_forecast, "A calendar with a cycle arc continuing into predicted days", modifier, size) /** * 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. + * The ring is empty because nothing has been recorded, not because something + * failed. It is the same ring the other illustrations fill with cycle days, + * which is what makes this read as *waiting* rather than *broken*. */ @Composable fun EmptyStateIllustration( - color: Color, modifier: Modifier = Modifier, size: Dp = IllustrationDefaults.Size, -) { - 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)), - ), - ) - } -} +) = Illustration(R.drawable.art_empty_state, "An empty cycle ring above a sunrise", modifier, size) /** - * The learning state — a cycle or two recorded, not yet a pattern. + * One drawing, sized by height and clipped to a rounded corner. * - * §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. + * **Height rather than a square box**: the set does not share one aspect ratio, + * and forcing a square would letterbox some and crop others. Each illustration + * keeps its own proportions and every screen agrees on how tall the artwork is, + * which is the dimension that decides whether the button below it stays on + * screen. + * + * **Clipped because the corners are painted in.** Each drawing is composed as a + * card with its own rounded corner, so the pixels outside that curve are the + * card's own backdrop — near-black in the dark set, near-white in the light one. + * Drawn unclipped they appear as four notches against the app's background, + * which is obvious the moment you look at a device and invisible in code review. + * Clipping slightly inside the painted curve removes them, and what is left + * reads as a deliberate rounded card rather than a pasted rectangle. */ @Composable -fun LearningIllustration( - color: Color, - accent: Color, - modifier: Modifier = Modifier, - size: Dp = IllustrationDefaults.Size, +private fun Illustration( + @DrawableRes id: Int, + description: String, + modifier: Modifier, + size: Dp, ) { - 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, - modifier: Modifier = Modifier, - size: Dp = IllustrationDefaults.Size, -) { - 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, - modifier: Modifier = Modifier, - size: Dp = 56.dp, -) { - 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))) - } + Image( + painter = painterResource(id), + contentDescription = description, + modifier = modifier + .height(size) + .clip(RoundedCornerShape(IllustrationDefaults.CornerRadius)), + contentScale = ContentScale.FillHeight, + ) } object IllustrationDefaults { val Size: Dp = 160.dp + + /** + * Slightly wider than the curve painted into the artwork, so the clip lands + * inside it and no corner of the source card's backdrop survives. + */ + val CornerRadius: Dp = 18.dp } diff --git a/core/designsystem/src/main/res/drawable-night/art_empty_state.webp b/core/designsystem/src/main/res/drawable-night/art_empty_state.webp new file mode 100644 index 0000000..d280f36 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-night/art_empty_state.webp differ diff --git a/core/designsystem/src/main/res/drawable-night/art_forecast.webp b/core/designsystem/src/main/res/drawable-night/art_forecast.webp new file mode 100644 index 0000000..8a738b0 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-night/art_forecast.webp differ diff --git a/core/designsystem/src/main/res/drawable-night/art_last_period.webp b/core/designsystem/src/main/res/drawable-night/art_last_period.webp new file mode 100644 index 0000000..579e876 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-night/art_last_period.webp differ diff --git a/core/designsystem/src/main/res/drawable-night/art_learning.webp b/core/designsystem/src/main/res/drawable-night/art_learning.webp new file mode 100644 index 0000000..71b697f Binary files /dev/null and b/core/designsystem/src/main/res/drawable-night/art_learning.webp differ diff --git a/core/designsystem/src/main/res/drawable-night/art_notification_privacy.webp b/core/designsystem/src/main/res/drawable-night/art_notification_privacy.webp new file mode 100644 index 0000000..188e663 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-night/art_notification_privacy.webp differ diff --git a/core/designsystem/src/main/res/drawable-night/art_period_end.webp b/core/designsystem/src/main/res/drawable-night/art_period_end.webp new file mode 100644 index 0000000..956afdb Binary files /dev/null and b/core/designsystem/src/main/res/drawable-night/art_period_end.webp differ diff --git a/core/designsystem/src/main/res/drawable-night/art_privacy.webp b/core/designsystem/src/main/res/drawable-night/art_privacy.webp new file mode 100644 index 0000000..b6d9617 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-night/art_privacy.webp differ diff --git a/core/designsystem/src/main/res/drawable-night/art_welcome.webp b/core/designsystem/src/main/res/drawable-night/art_welcome.webp new file mode 100644 index 0000000..de65140 Binary files /dev/null and b/core/designsystem/src/main/res/drawable-night/art_welcome.webp differ diff --git a/core/designsystem/src/main/res/drawable/art_empty_state.webp b/core/designsystem/src/main/res/drawable/art_empty_state.webp new file mode 100644 index 0000000..b697fc6 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/art_empty_state.webp differ diff --git a/core/designsystem/src/main/res/drawable/art_forecast.webp b/core/designsystem/src/main/res/drawable/art_forecast.webp new file mode 100644 index 0000000..0d8cb5c Binary files /dev/null and b/core/designsystem/src/main/res/drawable/art_forecast.webp differ diff --git a/core/designsystem/src/main/res/drawable/art_last_period.webp b/core/designsystem/src/main/res/drawable/art_last_period.webp new file mode 100644 index 0000000..45994b5 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/art_last_period.webp differ diff --git a/core/designsystem/src/main/res/drawable/art_learning.webp b/core/designsystem/src/main/res/drawable/art_learning.webp new file mode 100644 index 0000000..56cb916 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/art_learning.webp differ diff --git a/core/designsystem/src/main/res/drawable/art_notification_privacy.webp b/core/designsystem/src/main/res/drawable/art_notification_privacy.webp new file mode 100644 index 0000000..d6129d4 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/art_notification_privacy.webp differ diff --git a/core/designsystem/src/main/res/drawable/art_period_end.webp b/core/designsystem/src/main/res/drawable/art_period_end.webp new file mode 100644 index 0000000..b0e347e Binary files /dev/null and b/core/designsystem/src/main/res/drawable/art_period_end.webp differ diff --git a/core/designsystem/src/main/res/drawable/art_privacy.webp b/core/designsystem/src/main/res/drawable/art_privacy.webp new file mode 100644 index 0000000..3c26a64 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/art_privacy.webp differ diff --git a/core/designsystem/src/main/res/drawable/art_welcome.webp b/core/designsystem/src/main/res/drawable/art_welcome.webp new file mode 100644 index 0000000..fb99fa0 Binary files /dev/null and b/core/designsystem/src/main/res/drawable/art_welcome.webp differ diff --git a/docs/design/README.md b/docs/design/README.md index 7e50478..caf2781 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -135,19 +135,40 @@ 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 in-app artwork is placeholder, and says so here +## The artwork is real now, and the replaceable names are why that was cheap -Every illustration and calendar marker in the app is a **Compose vector path** -in [`core/designsystem/src/main/kotlin/dev/privacyllc/period/designsystem/art/`](../../core/designsystem/src/main/kotlin/dev/privacyllc/period/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. +Every illustration in the app is the owner's own drawing, in a matched light and +dark pair, in +[`core/designsystem/src/main/res/`](../../core/designsystem/src/main/res/). The +sources and the decisions behind them are in [`dist/`](dist/). -Real brand artwork exists — the owner's sources in [`brand/`](brand/) and the -three exported marks in [`../data/img/`](../data/img/) — and none of it is in the -app yet. The launcher icon keeps its simplified vector because the supplied -emblem has content close to its edges and an adaptive icon masks about a quarter -of the canvas away, so dropping it in unmodified would crop the shield. Fitting -it to the safe zone is Batch 08's final-artwork work. +**That swap changed one function body per illustration and no screen's layout**, +because PRODUCT_PLAN.md §42's advice was followed literally when there was +nothing to draw with: polished placeholders behind replaceable names. The names +did not change when the drawings did. That is the whole argument for the rule, +and it is worth keeping for the artwork still to come. + +**These are raster, and §42 still holds.** Its preference list ends "avoid +*unnecessary* raster imagery", and it offers placeholder vectors as the fallback +for artwork that does not exist. The artwork exists, and it is gradient +landscapes with glow and depth — there is no honest `VectorDrawable` of one. The +whole set costs 130 KB of the release APK. + +**The calendar markers stay vectors**, and so does `CycleProgressMark`. They are +drawn *from data* — a marker's shape is what a day is, and the progress arc is +where the user actually stands in their cycle. No amount of good artwork makes +those images. + +**The theme swap is resource resolution, not a branch.** Each drawable has a +`drawable-night/` twin, so Android picks it and the artwork follows a device +theme change with no code of ours involved. The cost is that a missing night +asset is invisible in light mode, which is why the preview pair in +`OnboardingPreviews.kt` has one entry per illustration. + +Still placeholder: **the launcher icon**, which keeps its simplified vector +because the supplied emblem has content close to its edges and an adaptive icon +masks about a quarter of the canvas away, so dropping it in unmodified would crop +the shield. Fitting it to the safe zone is issue #30. ### What the artwork has to do @@ -164,14 +185,12 @@ vocabulary — `PrivacyIllustration` encloses a ring in a shield, and enclosure reads as protection without reaching for a padlock, which would read as a security product rather than a calm one. -The onboarding half of that brief now has a decided direction: seven concept -cards, one per step, in [`dist/`](dist/). They are references and not shipping -assets — raster, with their titles painted into the pixels, light mode only, and -card 7 shows a fertile window the first-forecast screen deliberately withholds. -[`dist/README.md`](dist/README.md) says why each of those disqualifies them from -`res/drawable`, so that nobody mistakes a decided direction for a finished asset. +The onboarding brief has been answered: eight illustrations, both themes, now +shipping in the app. [`dist/`](dist/) holds the sources and the reasoning — no +text painted into the pixels, no step numbers, and a forecast illustration that +names no fields so it cannot contradict the live one beside it. -The remaining art is tracked as issues #29 to #32, not listed here. +The art still outstanding is tracked as issues #30 to #32, not listed here. The visual language is deliberately narrow — **overlapping circular forms** and nothing else. §42's forbidden list (blood drops, tampons, pads, uterus imagery, diff --git a/docs/design/dist/README.md b/docs/design/dist/README.md index e613c3e..94b9bbf 100644 --- a/docs/design/dist/README.md +++ b/docs/design/dist/README.md @@ -62,26 +62,28 @@ to reintroduce by regenerating carelessly: - **The dark set is a real dark composition**, not the light one dimmed — the midnight-plum ground `BRAND_GUIDE.md` §22 specifies. -## Still to do before anything ships +## These now ship -Tracked as **#29**, which stays open until the app renders these: +**#29 is closed.** All eight are in the app, in both themes, as WebP drawables in +[`../../../core/designsystem/src/main/res/`](../../../core/designsystem/src/main/res/) +— `drawable/` for light and `drawable-night/` for dark, so Android resolves the +theme rather than any code of ours. The files here stay as the full-resolution +sources; the shipped copies are downscaled to the height they are actually drawn +at. -1. Redraw as Compose vector paths behind the existing replaceable names, taking - colour from theme tokens rather than shipping two raster sets. -2. Add the light/dark preview pair per step, per [`../README.md`](../README.md). -3. Reconcile step copy — `PRODUCT_PLAN.md` owns the screen wording. +They are **not** redrawn as vectors, which is what #29 originally called for. +That was the right plan for artwork that did not exist and the wrong one for +gradient landscapes: there is no honest `VectorDrawable` of one, §42 objects only +to *unnecessary* raster, and the whole set costs 130 KB of the release APK. +`CycleMarkers.kt` keeps its vectors, because a marker's shape carries meaning and +the progress arc is drawn from the user's own cycle. -**One inconsistency to settle when these are redrawn:** the fourteen onboarding -illustrations are contained cards with a rounded frame, and the two -`EmptyStateIllustration` files are full-bleed with no frame. Both scale -acceptably at the 110–160 dp these are actually drawn at — checked by rendering -them at size rather than assumed — but the frame should be consistent across the -set, or deliberately absent from all of it. - -A second, smaller one: `TodayScreen.kt` currently calls -`EmptyStateIllustration(color = ..., size = ...)` with a single colour. These are -full-colour drawings, so the vector version will not take a colour parameter and -that call site changes with it. +**The painted-in corners needed clipping.** Each drawing is composed as a card +with its own rounded corner, so the pixels outside that curve are the card's own +backdrop — near-black in the dark set, near-white in the light one. Drawn +unclipped they showed as four notches against the app background: obvious on a +device, invisible in code review. `Illustration` in `Illustrations.kt` clips just +inside the painted curve, and what is left reads as a deliberate rounded card. ## Format diff --git a/docs/history/DEVELOPMENT_LOG.md b/docs/history/DEVELOPMENT_LOG.md index 5993956..db38a8f 100644 --- a/docs/history/DEVELOPMENT_LOG.md +++ b/docs/history/DEVELOPMENT_LOG.md @@ -32,6 +32,63 @@ written and stay true. It is exempt from review for the same reason a receipt is ## Entries +### 2026-08-18 — The onboarding artwork ships, and a plan I had argued for turned out to be wrong + +All eight illustrations are in the app in both themes, every onboarding step has +art for the first time, and **#29 closes**. The interesting part is that the plan +I pushed hardest for is not the one that shipped. + +**I argued for vectors and was wrong.** The issue said redraw the set as Compose +vector paths taking colour from theme tokens. That is the right answer for +artwork that does not exist yet — it is exactly what §42 prescribes and what the +placeholders did. It is the wrong answer for gradient landscapes with glow and +depth: there is no honest `VectorDrawable` of one, and the attempt would either +lose the drawing or produce path soup slower than the image. §42 objects to +*unnecessary* raster, and I had been reading it as objecting to raster. + +The number settled it. All sixteen files at the height they are actually drawn +at cost **130 KB**: the release APK went 1.90 MB to 2.03 MB. I had been arguing +against a cost I never measured. + +**The theme swap is resource resolution, not a branch.** `drawable/` and +`drawable-night/`, so Android picks the asset and the artwork follows a device +theme change with nothing of ours running. It also means a missing night asset is +invisible in light mode, which is why `OnboardingPreviews.kt` now has one entry +per illustration rather than a representative few. + +**What only a device showed.** Each drawing is composed as a card with its own +rounded corner painted in, so the pixels outside that curve are the card's own +backdrop — near-black in the dark set. Rendered unclipped, every illustration had +four visible notches against the app background. Nothing in the build says so, +the previews are small, and it was obvious within a second of looking at a phone. +`Illustration` now clips just inside the painted curve. + +**Two vectors stayed vectors**, and the line is worth keeping: the calendar +markers and `CycleProgressMark` are drawn *from data* — a marker's shape is what +a day is, and the progress arc is where the user actually stands in their cycle. +Good artwork does not make those into images. `CycleProgressMark` moved from +`Illustrations.kt` to `CycleMarkers.kt` to put that boundary in the file layout. + +**Sizing is by height, not a square box.** The set does not share one aspect +ratio and a square would letterbox some and crop others. Height is also the +dimension that decides whether the primary button stays on screen, so steps 3 and +6 — three buttons and three option cards respectively — take 104 dp where the +others take 120–128 dp. Checked by driving all seven steps rather than by +reasoning about it. + +Driven end to end on `PeriodMinSdk26` in both themes, no crash. + +- **Closed:** #29. +- **Next action:** Batch 06 — Privacy and Security. File its issues first; the + milestone is still empty. Biometric and PIN lock, export, irreversible delete, + the privacy promise in Settings, and making §45's logging rules true in a + release build. +- **Blockers:** #9, the Command Center webhook, still needs a person. The QA gaps + are unchanged: no real lock screen has been looked at, TalkBack has never been + run, and text has never been scaled — the last one now matters more, because + every onboarding step has an illustration above its heading and nobody has seen + that layout at the largest font scale. + ### 2026-08-18 — The app ran at `minSdk` for the first time A second emulator exists — `PeriodMinSdk26`, API 26, Pixel 6 — and the app was