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() {
|
private fun IllustrationPreviews() {
|
||||||
PeriodTheme {
|
PeriodTheme {
|
||||||
Column(
|
Column(
|
||||||
Modifier.fillMaxWidth().padding(16.dp),
|
Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Label("1 · Welcome")
|
Label("1 · Welcome")
|
||||||
WelcomeIllustration(size = 110.dp)
|
WelcomeIllustration(size = 150.dp)
|
||||||
Label("2 · Last period start")
|
Label("2 · Last period start")
|
||||||
LastPeriodIllustration(size = 110.dp)
|
LastPeriodIllustration(size = 150.dp)
|
||||||
Label("3 · Period end")
|
Label("3 · Period end")
|
||||||
PeriodEndIllustration(size = 110.dp)
|
PeriodEndIllustration(size = 150.dp)
|
||||||
Label("4 · Learning, and Insights empty")
|
Label("4 · Learning, and Insights empty")
|
||||||
LearningIllustration(size = 110.dp)
|
LearningIllustration(size = 150.dp)
|
||||||
Label("5 · Privacy promise")
|
Label("5 · Privacy promise")
|
||||||
PrivacyIllustration(size = 110.dp)
|
PrivacyIllustration(size = 150.dp)
|
||||||
Label("6 · Reminder privacy")
|
Label("6 · Reminder privacy")
|
||||||
NotificationPrivacyIllustration(size = 110.dp)
|
NotificationPrivacyIllustration(size = 150.dp)
|
||||||
Label("7 · First forecast")
|
Label("7 · First forecast")
|
||||||
ForecastIllustration(size = 110.dp)
|
ForecastIllustration(size = 150.dp)
|
||||||
Label("Today empty state")
|
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.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.semantics.Role
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
|
@ -77,11 +79,14 @@ fun OnboardingScreen(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(24.dp),
|
.padding(vertical = 24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
when (state.step) {
|
when (state.step) {
|
||||||
|
|
@ -94,23 +99,68 @@ fun OnboardingScreen(
|
||||||
Step.FIRST_FORECAST -> FirstForecast(state) { viewModel.complete(onFinished) }
|
Step.FIRST_FORECAST -> FirstForecast(state) { viewModel.complete(onFinished) }
|
||||||
}
|
}
|
||||||
|
|
||||||
state.message?.let {
|
// These sit outside the step composables, so they carry the inset
|
||||||
Spacer(Modifier.height(16.dp))
|
// the parent column no longer applies.
|
||||||
Card { Text(it, Modifier.padding(12.dp), style = MaterialTheme.typography.bodyMedium) }
|
Column(
|
||||||
TextButton(onClick = viewModel::messageShown) { Text("OK") }
|
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) {
|
if (state.step != Step.WELCOME && state.step != Step.FIRST_FORECAST) {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
TextButton(onClick = viewModel::back) { Text("Back") }
|
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
|
@Composable
|
||||||
private fun Heading(title: String, body: String? = null) {
|
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) {
|
if (body != null) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -125,21 +175,24 @@ private fun Heading(title: String, body: String? = null) {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Welcome(onNext: () -> Unit) {
|
private fun Welcome(onNext: () -> Unit) {
|
||||||
Spacer(Modifier.height(32.dp))
|
StepBody(
|
||||||
WelcomeIllustration()
|
artHeight = 280.dp,
|
||||||
Spacer(Modifier.height(32.dp))
|
art = { WelcomeIllustration(size = it) },
|
||||||
Heading("Know what's coming.", "Track your period and get predictions that learn your cycle.")
|
title = "Know what's coming.",
|
||||||
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { Text("Get Started") }
|
body = "Track your period and get predictions that learn your cycle.",
|
||||||
|
) {
|
||||||
|
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { Text("Get Started") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun LastPeriod(state: OnboardingUiState, viewModel: OnboardingViewModel) {
|
private fun LastPeriod(state: OnboardingUiState, viewModel: OnboardingViewModel) {
|
||||||
Spacer(Modifier.height(24.dp))
|
StepBody(
|
||||||
LastPeriodIllustration(size = 128.dp)
|
artHeight = 280.dp,
|
||||||
Spacer(Modifier.height(24.dp))
|
art = { LastPeriodIllustration(size = it) },
|
||||||
Heading("When did your last period start?")
|
title = "When did your last period start?",
|
||||||
|
) {
|
||||||
DateField(
|
DateField(
|
||||||
label = state.lastPeriodStart?.pretty() ?: "Choose a date",
|
label = state.lastPeriodStart?.pretty() ?: "Choose a date",
|
||||||
selected = state.lastPeriodStart,
|
selected = state.lastPeriodStart,
|
||||||
|
|
@ -153,17 +206,19 @@ private fun LastPeriod(state: OnboardingUiState, viewModel: OnboardingViewModel)
|
||||||
enabled = state.canContinueFromLastPeriod,
|
enabled = state.canContinueFromLastPeriod,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text("Continue") }
|
) { Text("Continue") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PeriodEnd(state: OnboardingUiState, viewModel: OnboardingViewModel) {
|
private fun PeriodEnd(state: OnboardingUiState, viewModel: OnboardingViewModel) {
|
||||||
Spacer(Modifier.height(16.dp))
|
// A shorter hero than the other steps: this one carries three buttons under
|
||||||
// Smaller than the other steps: this one carries three buttons under the
|
// the date field, and the primary must stay reachable without scrolling.
|
||||||
// date field, and the primary must stay reachable without scrolling.
|
// Verified at font scale 2.0, which is how the 104dp version was chosen.
|
||||||
PeriodEndIllustration(size = 104.dp)
|
StepBody(
|
||||||
Spacer(Modifier.height(16.dp))
|
artHeight = 200.dp,
|
||||||
Heading("When did it end?")
|
art = { PeriodEndIllustration(size = it) },
|
||||||
|
title = "When did it end?",
|
||||||
|
) {
|
||||||
var picked by remember { mutableStateOf<LocalDate?>(null) }
|
var picked by remember { mutableStateOf<LocalDate?>(null) }
|
||||||
DateField(
|
DateField(
|
||||||
label = picked?.pretty() ?: "Choose a date",
|
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") }
|
OutlinedButton(onClick = { viewModel.setPeriodEnd(null) }, Modifier.fillMaxWidth()) { Text("Still going") }
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
OutlinedButton(onClick = { viewModel.setPeriodEnd(null) }, Modifier.fillMaxWidth()) { Text("I'm not sure") }
|
OutlinedButton(onClick = { viewModel.setPeriodEnd(null) }, Modifier.fillMaxWidth()) { Text("I'm not sure") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PreviousHistory(state: OnboardingUiState, viewModel: OnboardingViewModel) {
|
private fun PreviousHistory(state: OnboardingUiState, viewModel: OnboardingViewModel) {
|
||||||
Spacer(Modifier.height(24.dp))
|
StepBody(
|
||||||
LearningIllustration(size = 120.dp)
|
artHeight = 240.dp,
|
||||||
Spacer(Modifier.height(24.dp))
|
art = { LearningIllustration(size = it) },
|
||||||
Heading(
|
title = "Remember any earlier periods?",
|
||||||
"Remember any earlier periods?",
|
body = "Adding previous dates helps us learn your cycle faster.",
|
||||||
"Adding previous dates helps us learn your cycle faster.",
|
) {
|
||||||
)
|
|
||||||
|
|
||||||
state.previousStarts.forEach { date ->
|
state.previousStarts.forEach { date ->
|
||||||
Row(
|
Row(
|
||||||
Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
|
@ -220,35 +274,36 @@ private fun PreviousHistory(state: OnboardingUiState, viewModel: OnboardingViewM
|
||||||
Button(onClick = viewModel::donePreviousHistory, modifier = Modifier.fillMaxWidth()) {
|
Button(onClick = viewModel::donePreviousHistory, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(if (state.previousStarts.isEmpty()) "Skip" else "Continue")
|
Text(if (state.previousStarts.isEmpty()) "Skip" else "Continue")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PrivacyPromise(onNext: () -> Unit) {
|
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
|
// §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
|
// 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
|
// 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
|
// ad SDK will, and a promise the implementation cannot keep is worse than a
|
||||||
// narrower one it can.
|
// narrower one it can.
|
||||||
Heading(
|
StepBody(
|
||||||
"Your cycle belongs to you.",
|
artHeight = 280.dp,
|
||||||
"We will never sell your personal or health data.\n\n" +
|
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 " +
|
"Your period history and fertility information are private. We don't sell them " +
|
||||||
"to advertisers, data brokers, or third parties.",
|
"to advertisers, data brokers, or third parties.",
|
||||||
)
|
) {
|
||||||
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { Text("Continue") }
|
Button(onClick = onNext, modifier = Modifier.fillMaxWidth()) { Text("Continue") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NotificationPrivacyStep(state: OnboardingUiState, viewModel: OnboardingViewModel) {
|
private fun NotificationPrivacyStep(state: OnboardingUiState, viewModel: OnboardingViewModel) {
|
||||||
Spacer(Modifier.height(16.dp))
|
// Also shorter: three option cards and a button follow it.
|
||||||
// Also small: three option cards and a button follow it.
|
StepBody(
|
||||||
NotificationPrivacyIllustration(size = 104.dp)
|
artHeight = 200.dp,
|
||||||
Spacer(Modifier.height(16.dp))
|
art = { NotificationPrivacyIllustration(size = it) },
|
||||||
Heading("How should reminders appear?")
|
title = "How should reminders appear?",
|
||||||
|
) {
|
||||||
Column(Modifier.selectableGroup().fillMaxWidth()) {
|
Column(Modifier.selectableGroup().fillMaxWidth()) {
|
||||||
PrivacyOption(
|
PrivacyOption(
|
||||||
title = "Discreet",
|
title = "Discreet",
|
||||||
|
|
@ -277,6 +332,7 @@ private fun NotificationPrivacyStep(state: OnboardingUiState, viewModel: Onboard
|
||||||
enabled = !state.saving,
|
enabled = !state.saving,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) { Text(if (state.saving) "Saving…" else "Continue") }
|
) { Text(if (state.saving) "Saving…" else "Continue") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -318,11 +374,11 @@ private fun PrivacyOption(title: String, example: String, selected: Boolean, onS
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FirstForecast(state: OnboardingUiState, onDone: () -> Unit) {
|
private fun FirstForecast(state: OnboardingUiState, onDone: () -> Unit) {
|
||||||
Spacer(Modifier.height(24.dp))
|
StepBody(
|
||||||
ForecastIllustration(size = 120.dp)
|
artHeight = 240.dp,
|
||||||
Spacer(Modifier.height(24.dp))
|
art = { ForecastIllustration(size = it) },
|
||||||
Heading("Your first forecast")
|
title = "Your first forecast",
|
||||||
|
) {
|
||||||
val forecast = state.forecast
|
val forecast = state.forecast
|
||||||
if (forecast == null) {
|
if (forecast == null) {
|
||||||
Text(
|
Text(
|
||||||
|
|
@ -354,6 +410,7 @@ private fun FirstForecast(state: OnboardingUiState, onDone: () -> Unit) {
|
||||||
|
|
||||||
Spacer(Modifier.height(28.dp))
|
Spacer(Modifier.height(28.dp))
|
||||||
Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { Text("Go to Today") }
|
Button(onClick = onDone, modifier = Modifier.fillMaxWidth()) { Text("Go to Today") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -2,119 +2,124 @@ package dev.privacyllc.period.designsystem.art
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.compose.foundation.Image
|
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.layout.height
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.layout.ContentScale
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.privacyllc.period.designsystem.R
|
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
|
* ## Why these are raster, when PRODUCT_PLAN.md §42 asks for vectors
|
||||||
*
|
*
|
||||||
* §42's preference list ends *"avoid **unnecessary** raster imagery"*, and it
|
* §42's preference list ends *"avoid **unnecessary** raster imagery"*, and it
|
||||||
* offers polished placeholder vectors as the fallback for artwork that does not
|
* 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.
|
* 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
|
* They are gradient landscapes with soft glow and depth. There is no honest
|
||||||
* `VectorDrawable` of one; converting them would either lose the drawing or
|
* `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
|
* produce a path soup larger and slower than the image.
|
||||||
* necessary rather than unnecessary, and the whole set costs 216 KB.
|
|
||||||
*
|
*
|
||||||
* [CycleProgressMark] stays a vector, because it is the one mark that is
|
* [CycleProgressMark] and the calendar markers stay vectors, because they are
|
||||||
* *drawn from data* — its arc is the user's actual position in their cycle.
|
* 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
|
* ## How the theme swap works
|
||||||
*
|
*
|
||||||
* There is no `isSystemInDarkTheme()` branch here on purpose. Each drawable has
|
* There is no `isSystemInDarkTheme()` branch here on purpose. Each drawable has
|
||||||
* a `drawable/` and a `drawable-night/` copy, and Android resolves it. That
|
* a `-night` twin at every density, and Android resolves it. Nothing of ours
|
||||||
* means the dark art also follows a device-level theme change with no
|
* runs on a theme change. The cost is that a missing night asset is invisible in
|
||||||
* recomposition of ours, and a screenshot test of either theme picks the right
|
* light mode, which is why `OnboardingPreviews.kt` renders one entry per
|
||||||
* one for free.
|
* illustration — and why #42 exists.
|
||||||
*
|
*
|
||||||
* ## Sources
|
* ## Sources
|
||||||
*
|
*
|
||||||
* `docs/design/dist/` holds the delivered set at full resolution, with a README
|
* `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
|
* recording the decisions behind them — no text in the pixels, no step numbers,
|
||||||
* forecast illustration that deliberately names no fields. Re-read it before
|
* and a forecast illustration that deliberately names no fields. **Re-read it
|
||||||
* regenerating any of these.
|
* before regenerating any of these.**
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Step 1. Overlapping rings over a dawn landscape — the cycle, before it means anything specific. */
|
/** Step 1. Overlapping rings over a dawn landscape — the cycle, before it means anything specific. */
|
||||||
@Composable
|
@Composable
|
||||||
fun WelcomeIllustration(
|
fun WelcomeIllustration(modifier: Modifier = Modifier, size: Dp = IllustrationDefaults.Size) =
|
||||||
modifier: Modifier = Modifier,
|
Illustration(R.drawable.art_welcome, "Overlapping circles above a sunrise", modifier, size)
|
||||||
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. */
|
/** Step 2. A cycle ring with one marked day — the first day being recorded. */
|
||||||
@Composable
|
@Composable
|
||||||
fun LastPeriodIllustration(
|
fun LastPeriodIllustration(modifier: Modifier = Modifier, size: Dp = IllustrationDefaults.Size) =
|
||||||
modifier: Modifier = Modifier,
|
Illustration(R.drawable.art_last_period, "A cycle ring with one marked day", modifier, size)
|
||||||
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. */
|
/** Step 3. The same ring with a span between two marked days — a period with a start and an end. */
|
||||||
@Composable
|
@Composable
|
||||||
fun PeriodEndIllustration(
|
fun PeriodEndIllustration(modifier: Modifier = Modifier, size: Dp = IllustrationDefaults.Size) =
|
||||||
modifier: Modifier = Modifier,
|
Illustration(R.drawable.art_period_end, "A cycle ring with a marked span between two days", modifier, size)
|
||||||
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.
|
* Step 4, and the Insights empty state.
|
||||||
*
|
*
|
||||||
* Earlier cycles fading behind the current one — §42's "points gradually
|
* Earlier cycles fading behind the current one. Used in two places, which is why
|
||||||
* converging into a pattern", which is the honest picture of what the engine
|
* it carries no step number.
|
||||||
* does with history. Used in two places, which is why it carries no step number.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LearningIllustration(
|
fun LearningIllustration(modifier: Modifier = Modifier, size: Dp = IllustrationDefaults.Size) =
|
||||||
modifier: Modifier = Modifier,
|
Illustration(R.drawable.art_learning, "Earlier cycle rings fading behind the current one", modifier, size)
|
||||||
size: Dp = IllustrationDefaults.Size,
|
|
||||||
) = Illustration(R.drawable.art_learning, "Earlier cycle rings fading behind the current one", modifier, size)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 5, the privacy promise.
|
* Step 5, the privacy promise.
|
||||||
*
|
*
|
||||||
* A shield with the cycle ring inside it. Putting the cycle *inside* the shield
|
* A shield with the cycle ring inside it. Enclosure reads as protection without
|
||||||
* is the whole sentence the screen is making, and enclosure reads as protection
|
* reaching for a padlock, which would make a calm product look like a security
|
||||||
* without reaching for a padlock — which would make a calm product look like a
|
* one.
|
||||||
* security one.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun PrivacyIllustration(
|
fun PrivacyIllustration(modifier: Modifier = Modifier, size: Dp = IllustrationDefaults.Size) =
|
||||||
modifier: Modifier = Modifier,
|
Illustration(R.drawable.art_privacy, "A shield containing a cycle ring", modifier, size)
|
||||||
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. */
|
/** Step 6. A notification with a shield on it — what a reminder shows, and what it withholds. */
|
||||||
@Composable
|
@Composable
|
||||||
fun NotificationPrivacyIllustration(
|
fun NotificationPrivacyIllustration(modifier: Modifier = Modifier, size: Dp = IllustrationDefaults.Size) =
|
||||||
modifier: Modifier = Modifier,
|
Illustration(R.drawable.art_notification_privacy, "A notification card marked with a shield", modifier, size)
|
||||||
size: Dp = IllustrationDefaults.Size,
|
|
||||||
) = Illustration(R.drawable.art_notification_privacy, "A notification card marked with a shield", modifier, size)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 7, the first forecast.
|
* Step 7, the first forecast.
|
||||||
*
|
*
|
||||||
* A calendar and a cycle arc running from logged days into dashed predicted
|
* 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"
|
* ones. It deliberately names no fields: a drawing that said "fertile window" or
|
||||||
* or "confidence: low" would contradict the live forecast beside it, which on a
|
* "confidence: low" would contradict the live forecast beside it, which on a
|
||||||
* first run declines to estimate fertility at all.
|
* first run declines to estimate fertility at all.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ForecastIllustration(
|
fun ForecastIllustration(modifier: Modifier = Modifier, size: Dp = IllustrationDefaults.Size) =
|
||||||
modifier: Modifier = Modifier,
|
Illustration(R.drawable.art_forecast, "A calendar with a cycle arc continuing into predicted days", modifier, size)
|
||||||
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.
|
* The empty state — no periods logged yet.
|
||||||
|
|
@ -124,27 +129,32 @@ fun ForecastIllustration(
|
||||||
* which is what makes this read as *waiting* rather than *broken*.
|
* which is what makes this read as *waiting* rather than *broken*.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun EmptyStateIllustration(
|
fun EmptyStateIllustration(modifier: Modifier = Modifier, size: Dp = IllustrationDefaults.Size) =
|
||||||
modifier: Modifier = Modifier,
|
Illustration(R.drawable.art_empty_state, "An empty cycle ring above a sunrise", modifier, size)
|
||||||
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,
|
* **Full-bleed, and no clip.** These are 3:2 scenes composed to reach every
|
||||||
* and forcing a square would letterbox some and crop others. Each illustration
|
* edge. An earlier set was composed as cards with their own rounded corners, and
|
||||||
* keeps its own proportions and every screen agrees on how tall the artwork is,
|
* the corners had to be clipped off; that is gone, and putting a `clip` back
|
||||||
* which is the dimension that decides whether the button below it stays on
|
* would cut the artwork rather than tidy it.
|
||||||
* screen.
|
|
||||||
*
|
*
|
||||||
* **Clipped because the corners are painted in.** Each drawing is composed as a
|
* **[size] is a ceiling, not a height.** It is capped at a third of the screen
|
||||||
* card with its own rounded corner, so the pixels outside that curve are the
|
* so the primary button below stays reachable. Landscape is the case that makes
|
||||||
* card's own backdrop — near-black in the dark set, near-white in the light one.
|
* this necessary: 914 × 411 dp, where a fixed 280 dp hero would be 68% of the
|
||||||
* Drawn unclipped they appear as four notches against the app's background,
|
* screen and every step would break on rotation.
|
||||||
* 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
|
* The cap reads `LocalConfiguration.screenHeightDp` rather than
|
||||||
* reads as a deliberate rounded card rather than a pasted rectangle.
|
* `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
|
@Composable
|
||||||
private fun Illustration(
|
private fun Illustration(
|
||||||
|
|
@ -153,22 +163,41 @@ private fun Illustration(
|
||||||
modifier: Modifier,
|
modifier: Modifier,
|
||||||
size: Dp,
|
size: Dp,
|
||||||
) {
|
) {
|
||||||
Image(
|
val cap = (LocalConfiguration.current.screenHeightDp * IllustrationDefaults.MaxScreenFraction).dp
|
||||||
painter = painterResource(id),
|
Box(modifier.fillMaxWidth().height(minOf(size, cap))) {
|
||||||
contentDescription = description,
|
Image(
|
||||||
modifier = modifier
|
painter = painterResource(id),
|
||||||
.height(size)
|
contentDescription = description,
|
||||||
.clip(RoundedCornerShape(IllustrationDefaults.CornerRadius)),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.FillHeight,
|
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 {
|
object IllustrationDefaults {
|
||||||
val Size: Dp = 160.dp
|
/** The hero height a step asks for, before the screen-fraction cap. */
|
||||||
|
val Size: Dp = 280.dp
|
||||||
|
|
||||||
/**
|
/** Never taller than this share of the screen — see [Illustration]. */
|
||||||
* Slightly wider than the curve painted into the artwork, so the clip lands
|
const val MaxScreenFraction = 0.32f
|
||||||
* inside it and no corner of the source card's backdrop survives.
|
|
||||||
*/
|
/** How much of the image the fade covers. The artwork keeps this band quiet. */
|
||||||
val CornerRadius: Dp = 18.dp
|
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
|
**These are raster, and §42 still holds.** Its preference list ends "avoid
|
||||||
*unnecessary* raster imagery", and it offers placeholder vectors as the fallback
|
*unnecessary* raster imagery", and it offers placeholder vectors as the fallback
|
||||||
for artwork that does not exist. The artwork exists, and it is gradient
|
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
|
landscapes with glow and depth — there is no honest `VectorDrawable` of one.
|
||||||
whole set costs 130 KB of the release APK.
|
|
||||||
|
**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
|
**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
|
drawn *from data* — a marker's shape is what a day is, and the progress arc is
|
||||||
|
|
|
||||||