feat: full-bleed onboarding heroes, in density buckets

closes #40, closes #41

The illustrations span the screen and fade into the page, at roughly a third of
its height, and the seven hand-rolled step layouts became one component.

A UNIFIED STEP

Every step wrote out Spacer / Illustration(size) / Spacer / Heading / controls,
in three different spacer rhythms with a reason recorded for only two. StepBody
holds the rhythm; a step declares what is different about it. artHeight stays
per-step: the two carrying three or four controls take a shorter hero so the
primary button is reachable without scrolling, which is the constraint the old
104dp values existed for.

The screen's padding moved from the scrolling column to the content inside each
step. Compose has no negative padding, so a full-bleed child cannot live inside
a padded parent.

THE CAP IS WHAT MAKES LANDSCAPE SURVIVE

A landscape phone is 914x411dp. A fixed 280dp hero would be 68% of it and every
step would break on rotation — nothing in this app uses WindowSizeClass and
onboarding does not lock orientation. Illustration caps at a third of the screen.

It reads LocalConfiguration.screenHeightDp rather than BoxWithConstraints: inside
a verticalScroll column a child is measured with an INFINITE height constraint,
so maxHeight there is Dp.Infinity and the cap would silently never apply. A guard
that looks right and does nothing.

DENSITY BUCKETS, FIXING A LIVE DEFECT

The old set sat in a density-less drawable/, treated as mdpi and pre-scaled at
decode — roughly 17 MB of heap for one bitmap on an xxxhdpi device, and this
artwork is far larger. Now four widths per theme, 617 to 1644 px, and no plain
drawable/ at all. 2.3 MB across every bucket; a release is an AAB and Play splits
by density, so a device downloads 130-500 KB.

That is also the error-handling fix: painterResource decodes inside composition
and there is no boundary anywhere, so an OOM would crash onboarding on first
launch. Sizing the bitmaps removes the failure rather than hiding it.

The clip is gone with the corners it existed for, and the bottom fade is drawn
from the theme background rather than baked into the art. Titles move to
headlineLarge, which is what 280dp of artwork above them needs.

Driven on PeriodMinSdk26: all steps, both themes, font scale 1.0 and 2.0, and
landscape. No crash, no OOM. ./gradlew check green.

KNOWN, FILED SEPARATELY: dark 01 Welcome is a forecast scene, so dark mode shows
a calendar on step 1. The artwork is wrong, not the wiring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-19 01:02:06 -05:00
parent b74bc4b66a
commit ae27ef6490
84 changed files with 244 additions and 147 deletions

View File

@ -54,26 +54,26 @@ import dev.privacyllc.period.designsystem.art.WelcomeIllustration
private fun IllustrationPreviews() {
PeriodTheme {
Column(
Modifier.fillMaxWidth().padding(16.dp),
Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Label("1 · Welcome")
WelcomeIllustration(size = 110.dp)
WelcomeIllustration(size = 150.dp)
Label("2 · Last period start")
LastPeriodIllustration(size = 110.dp)
LastPeriodIllustration(size = 150.dp)
Label("3 · Period end")
PeriodEndIllustration(size = 110.dp)
PeriodEndIllustration(size = 150.dp)
Label("4 · Learning, and Insights empty")
LearningIllustration(size = 110.dp)
LearningIllustration(size = 150.dp)
Label("5 · Privacy promise")
PrivacyIllustration(size = 110.dp)
PrivacyIllustration(size = 150.dp)
Label("6 · Reminder privacy")
NotificationPrivacyIllustration(size = 110.dp)
NotificationPrivacyIllustration(size = 150.dp)
Label("7 · First forecast")
ForecastIllustration(size = 110.dp)
ForecastIllustration(size = 150.dp)
Label("Today empty state")
EmptyStateIllustration(size = 110.dp)
EmptyStateIllustration(size = 150.dp)
}
}
}

View File

@ -2,6 +2,7 @@ package dev.privacyllc.period.feature.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@ -34,6 +35,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -77,11 +79,14 @@ fun OnboardingScreen(
modifier = Modifier.fillMaxWidth(),
)
// Vertical padding only. The hero illustration is full-bleed and Compose
// has no negative padding, so horizontal inset belongs to the content
// inside each step — see StepBody — rather than to this column.
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
.padding(vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when (state.step) {
@ -94,23 +99,68 @@ fun OnboardingScreen(
Step.FIRST_FORECAST -> FirstForecast(state) { viewModel.complete(onFinished) }
}
state.message?.let {
Spacer(Modifier.height(16.dp))
Card { Text(it, Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium) }
TextButton(onClick = viewModel::messageShown) { Text("OK") }
}
// These sit outside the step composables, so they carry the inset
// the parent column no longer applies.
Column(
Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
state.message?.let {
Spacer(Modifier.height(16.dp))
Card { Text(it, Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium) }
TextButton(onClick = viewModel::messageShown) { Text("OK") }
}
if (state.step != Step.WELCOME && state.step != Step.FIRST_FORECAST) {
Spacer(Modifier.height(8.dp))
TextButton(onClick = viewModel::back) { Text("Back") }
if (state.step != Step.WELCOME && state.step != Step.FIRST_FORECAST) {
Spacer(Modifier.height(8.dp))
TextButton(onClick = viewModel::back) { Text("Back") }
}
}
}
}
}
/**
* One onboarding step: a full-bleed hero, then inset content.
*
* Every step had hand-rolled this `Spacer / Illustration(size) / Spacer /
* Heading / controls` in three different spacer rhythms with a reason recorded
* for only two of them. The rhythm lives here now, and a step declares what is
* different about it rather than restating what is the same.
*
* [artHeight] stays per-step and is deliberately not uniform: the steps carrying
* three or four controls under the art take a smaller hero so the primary button
* is reachable without scrolling. `Illustration` caps whatever is passed at a
* third of the screen, which is what makes landscape survivable.
*
* The hero is emitted OUTSIDE the inset column, because it is full-bleed and the
* screen's horizontal padding would otherwise box it in.
*/
@Composable
private fun StepBody(
artHeight: Dp,
art: @Composable (Dp) -> Unit,
title: String,
body: String? = null,
content: @Composable ColumnScope.() -> Unit,
) {
art(artHeight)
Spacer(Modifier.height(24.dp))
Column(
Modifier.fillMaxWidth().padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Heading(title, body)
content()
}
}
@Composable
private fun Heading(title: String, body: String? = null) {
Text(title, style = MaterialTheme.typography.headlineMedium, textAlign = TextAlign.Center)
// headlineLarge, not headlineMedium: these titles sit under a hero that now
// fills a third of the screen, and 28sp under 280dp of artwork reads as a
// caption rather than a heading.
Text(title, style = MaterialTheme.typography.headlineLarge, textAlign = TextAlign.Center)
if (body != null) {
Spacer(Modifier.height(12.dp))
Text(
@ -125,21 +175,24 @@ private fun Heading(title: String, body: String? = null) {
@Composable
private fun Welcome(onNext: () -> Unit) {
Spacer(Modifier.height(32.dp))
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") }
StepBody(
artHeight = 280.dp,
art = { WelcomeIllustration(size = it) },
title = "Know what's coming.",
body = "Track your period and get predictions that learn your cycle.",
) {
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { Text("Get Started") }
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun LastPeriod(state: OnboardingUiState, viewModel: OnboardingViewModel) {
Spacer(Modifier.height(24.dp))
LastPeriodIllustration(size = 128.dp)
Spacer(Modifier.height(24.dp))
Heading("When did your last period start?")
StepBody(
artHeight = 280.dp,
art = { LastPeriodIllustration(size = it) },
title = "When did your last period start?",
) {
DateField(
label = state.lastPeriodStart?.pretty() ?: "Choose a date",
selected = state.lastPeriodStart,
@ -153,17 +206,19 @@ private fun LastPeriod(state: OnboardingUiState, viewModel: OnboardingViewModel)
enabled = state.canContinueFromLastPeriod,
modifier = Modifier.fillMaxWidth(),
) { Text("Continue") }
}
}
@Composable
private fun PeriodEnd(state: OnboardingUiState, viewModel: OnboardingViewModel) {
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?")
// A shorter hero than the other steps: this one carries three buttons under
// the date field, and the primary must stay reachable without scrolling.
// Verified at font scale 2.0, which is how the 104dp version was chosen.
StepBody(
artHeight = 200.dp,
art = { PeriodEndIllustration(size = it) },
title = "When did it end?",
) {
var picked by remember { mutableStateOf<LocalDate?>(null) }
DateField(
label = picked?.pretty() ?: "Choose a date",
@ -185,18 +240,17 @@ private fun PeriodEnd(state: OnboardingUiState, viewModel: OnboardingViewModel)
OutlinedButton(onClick = { viewModel.setPeriodEnd(null) }, Modifier.fillMaxWidth()) { Text("Still going") }
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = { viewModel.setPeriodEnd(null) }, Modifier.fillMaxWidth()) { Text("I'm not sure") }
}
}
@Composable
private fun PreviousHistory(state: OnboardingUiState, viewModel: OnboardingViewModel) {
Spacer(Modifier.height(24.dp))
LearningIllustration(size = 120.dp)
Spacer(Modifier.height(24.dp))
Heading(
"Remember any earlier periods?",
"Adding previous dates helps us learn your cycle faster.",
)
StepBody(
artHeight = 240.dp,
art = { LearningIllustration(size = it) },
title = "Remember any earlier periods?",
body = "Adding previous dates helps us learn your cycle faster.",
) {
state.previousStarts.forEach { date ->
Row(
Modifier.fillMaxWidth().padding(vertical = 2.dp),
@ -220,35 +274,36 @@ private fun PreviousHistory(state: OnboardingUiState, viewModel: OnboardingViewM
Button(onClick = viewModel::donePreviousHistory, modifier = Modifier.fillMaxWidth()) {
Text(if (state.previousStarts.isEmpty()) "Skip" else "Continue")
}
}
}
@Composable
private fun PrivacyPromise(onNext: () -> Unit) {
Spacer(Modifier.height(24.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
// claim no third party ever processes anything, because Play Billing and an
// ad SDK will, and a promise the implementation cannot keep is worse than a
// narrower one it can.
Heading(
"Your cycle belongs to you.",
"We will never sell your personal or health data.\n\n" +
StepBody(
artHeight = 280.dp,
art = { PrivacyIllustration(size = it) },
title = "Your cycle belongs to you.",
body = "We will never sell your personal or health data.\n\n" +
"Your period history and fertility information are private. We don't sell them " +
"to advertisers, data brokers, or third parties.",
)
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { Text("Continue") }
) {
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { Text("Continue") }
}
}
@Composable
private fun NotificationPrivacyStep(state: OnboardingUiState, viewModel: OnboardingViewModel) {
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?")
// Also shorter: three option cards and a button follow it.
StepBody(
artHeight = 200.dp,
art = { NotificationPrivacyIllustration(size = it) },
title = "How should reminders appear?",
) {
Column(Modifier.selectableGroup().fillMaxWidth()) {
PrivacyOption(
title = "Discreet",
@ -277,6 +332,7 @@ private fun NotificationPrivacyStep(state: OnboardingUiState, viewModel: Onboard
enabled = !state.saving,
modifier = Modifier.fillMaxWidth(),
) { Text(if (state.saving) "Saving…" else "Continue") }
}
}
@Composable
@ -318,11 +374,11 @@ private fun PrivacyOption(title: String, example: String, selected: Boolean, onS
@Composable
private fun FirstForecast(state: OnboardingUiState, onDone: () -> Unit) {
Spacer(Modifier.height(24.dp))
ForecastIllustration(size = 120.dp)
Spacer(Modifier.height(24.dp))
Heading("Your first forecast")
StepBody(
artHeight = 240.dp,
art = { ForecastIllustration(size = it) },
title = "Your first forecast",
) {
val forecast = state.forecast
if (forecast == null) {
Text(
@ -354,6 +410,7 @@ private fun FirstForecast(state: OnboardingUiState, onDone: () -> Unit) {
Spacer(Modifier.height(28.dp))
Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { Text("Go to Today") }
}
}
@Composable

View File

@ -2,119 +2,124 @@ package dev.privacyllc.period.designsystem.art
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.privacyllc.period.designsystem.R
/**
* The onboarding and empty-state illustrations.
* The onboarding and empty-state illustrations, drawn as full-bleed heroes.
*
* ## Why these are raster, when PRODUCT_PLAN.md §42 asks for vectors
*
* §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
* exist yet which is what used to be here. The artwork exists: eight
* illustrations supplied by the project owner, in a matched light and dark pair.
*
* 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.
* produce a path soup larger and slower than the image.
*
* [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.
* [CycleProgressMark] and the calendar markers stay vectors, because 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.
*
* ## Why there are density buckets and no plain `drawable/`
*
* A file in a density-less `drawable/` is treated as mdpi and pre-scaled at
* decode by the device's bucket. The previous 552 × 480 set cost roughly 17 MB
* of heap for one bitmap on an xxxhdpi device, and this artwork is far larger.
* So each illustration ships at four widths 617, 822, 1233 and 1644 px, which
* is a 411 dp screen at hdpi through xxxhdpi and Android decodes the one it
* needs. **Never put one of these back in a plain `drawable/`.**
*
* The whole set is 2.3 MB across every bucket, but a release is an AAB and Play
* splits by density, so a device downloads only its own: 130500 KB.
*
* ## How the theme swap works
*
* 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.
* a `-night` twin at every density, and Android resolves it. Nothing of ours
* runs on a theme change. The cost is that a missing night asset is invisible in
* light mode, which is why `OnboardingPreviews.kt` renders one entry per
* illustration and why #42 exists.
*
* ## 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.
* recording the decisions behind them no text in the pixels, no step numbers,
* and a forecast illustration that deliberately names no fields. **Re-read it
* before regenerating any of these.**
*/
/** Step 1. Overlapping rings over a dawn landscape — the cycle, before it means anything specific. */
@Composable
fun WelcomeIllustration(
modifier: Modifier = Modifier,
size: Dp = IllustrationDefaults.Size,
) = Illustration(R.drawable.art_welcome, "Overlapping circles above a sunrise", modifier, size)
fun WelcomeIllustration(modifier: Modifier = Modifier, size: Dp = IllustrationDefaults.Size) =
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)
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)
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.
* Earlier cycles fading behind the current one. 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)
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.
* A shield with the cycle ring inside it. 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)
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)
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
* 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)
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.
@ -124,27 +129,32 @@ fun ForecastIllustration(
* which is what makes this read as *waiting* rather than *broken*.
*/
@Composable
fun EmptyStateIllustration(
modifier: Modifier = Modifier,
size: Dp = IllustrationDefaults.Size,
) = Illustration(R.drawable.art_empty_state, "An empty cycle ring above a sunrise", modifier, size)
fun EmptyStateIllustration(modifier: Modifier = Modifier, size: Dp = IllustrationDefaults.Size) =
Illustration(R.drawable.art_empty_state, "An empty cycle ring above a sunrise", modifier, size)
/**
* One drawing, sized by height and clipped to a rounded corner.
* One drawing, full width, fading into the page.
*
* **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.
* **Full-bleed, and no clip.** These are 3:2 scenes composed to reach every
* edge. An earlier set was composed as cards with their own rounded corners, and
* the corners had to be clipped off; that is gone, and putting a `clip` back
* would cut the artwork rather than tidy it.
*
* **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.
* **[size] is a ceiling, not a height.** It is capped at a third of the screen
* so the primary button below stays reachable. Landscape is the case that makes
* this necessary: 914 × 411 dp, where a fixed 280 dp hero would be 68% of the
* screen and every step would break on rotation.
*
* The cap reads `LocalConfiguration.screenHeightDp` rather than
* `BoxWithConstraints`. Inside a `verticalScroll` column a child is measured
* with an **infinite** height constraint, so `maxHeight` there is `Dp.Infinity`
* and the cap would silently never apply a guard that looks right and does
* nothing.
*
* **The fade is drawn, not baked.** A gradient to
* `MaterialTheme.colorScheme.background` over the bottom third, so the scene
* settles into the page in whichever theme is showing and the artwork never
* hard-codes a colour it cannot know.
*/
@Composable
private fun Illustration(
@ -153,22 +163,41 @@ private fun Illustration(
modifier: Modifier,
size: Dp,
) {
Image(
painter = painterResource(id),
contentDescription = description,
modifier = modifier
.height(size)
.clip(RoundedCornerShape(IllustrationDefaults.CornerRadius)),
contentScale = ContentScale.FillHeight,
val cap = (LocalConfiguration.current.screenHeightDp * IllustrationDefaults.MaxScreenFraction).dp
Box(modifier.fillMaxWidth().height(minOf(size, cap))) {
Image(
painter = painterResource(id),
contentDescription = description,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
BottomFade()
}
}
/** The gradient that joins the artwork to the page. */
@Composable
private fun BoxScope.BottomFade() {
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.fillMaxHeight(IllustrationDefaults.FadeFraction)
.background(
Brush.verticalGradient(
listOf(Color.Transparent, MaterialTheme.colorScheme.background),
),
),
)
}
object IllustrationDefaults {
val Size: Dp = 160.dp
/** The hero height a step asks for, before the screen-fraction cap. */
val Size: Dp = 280.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
/** Never taller than this share of the screen — see [Illustration]. */
const val MaxScreenFraction = 0.32f
/** How much of the image the fade covers. The artwork keeps this band quiet. */
const val FadeFraction = 0.34f
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

View File

@ -169,8 +169,19 @@ 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.
landscapes with glow and depth — there is no honest `VectorDrawable` of one.
**They ship as full-bleed heroes in density buckets.** Each illustration spans
the screen and fades into the page, at a height capped to a third of the screen
so the primary button stays reachable — the cap is what makes landscape survive,
where the screen is 411 dp tall and an uncapped 280 dp hero would fill 68% of it.
Four buckets per theme, 617 to 1644 px; a plain `drawable/` would be treated as
mdpi and decoded at 4× on a dense screen. 2.3 MB across every bucket, but a
release is an AAB and Play splits by density, so a device downloads 130500 KB.
**The fade is drawn, not baked**, as a gradient to
`MaterialTheme.colorScheme.background`. Artwork cannot know which theme it is
sitting on, and a baked fade would be a hard-coded colour by another name.
**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