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>
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,6 +99,12 @@ fun OnboardingScreen(
|
|||
Step.FIRST_FORECAST -> FirstForecast(state) { viewModel.complete(onFinished) }
|
||||
}
|
||||
|
||||
// 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) }
|
||||
|
|
@ -107,10 +118,49 @@ fun OnboardingScreen(
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.")
|
||||
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,
|
||||
|
|
@ -154,16 +207,18 @@ private fun LastPeriod(state: OnboardingUiState, viewModel: OnboardingViewModel)
|
|||
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",
|
||||
|
|
@ -186,17 +241,16 @@ private fun PeriodEnd(state: OnboardingUiState, viewModel: OnboardingViewModel)
|
|||
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),
|
||||
|
|
@ -221,34 +275,35 @@ private fun PreviousHistory(state: OnboardingUiState, viewModel: OnboardingViewM
|
|||
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") }
|
||||
}
|
||||
}
|
||||
|
||||
@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",
|
||||
|
|
@ -278,6 +333,7 @@ private fun NotificationPrivacyStep(state: OnboardingUiState, viewModel: Onboard
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(if (state.saving) "Saving…" else "Continue") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
// The whole row is the target, not the radio.
|
||||
|
|
@ -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(
|
||||
|
|
@ -355,6 +411,7 @@ private fun FirstForecast(state: OnboardingUiState, onDone: () -> Unit) {
|
|||
Spacer(Modifier.height(28.dp))
|
||||
Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { Text("Go to Today") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Label(text: String) = Text(
|
||||
|
|
|
|||
|
|
@ -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: 130–500 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,
|
||||
) {
|
||||
val cap = (LocalConfiguration.current.screenHeightDp * IllustrationDefaults.MaxScreenFraction).dp
|
||||
Box(modifier.fillMaxWidth().height(minOf(size, cap))) {
|
||||
Image(
|
||||
painter = painterResource(id),
|
||||
contentDescription = description,
|
||||
modifier = modifier
|
||||
.height(size)
|
||||
.clip(RoundedCornerShape(IllustrationDefaults.CornerRadius)),
|
||||
contentScale = ContentScale.FillHeight,
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
|
@ -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 130–500 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
|
||||
|
|
|
|||