feat: the designed Settings screen, structured as §36 specifies

closes #33

Settings is now a root with §36's sections rather than one screen of notification
toggles that called itself a working surface. Reminders move underneath it as a
child destination; the banner is gone because the claim it made stopped being
true, not because the screen changed.

A ROW FOR AN UNBUILT FEATURE IS ABSENT, NOT DISABLED

§36 lists six sections and this shows the two that have something behind them.
A greyed-out "Export My Data" is a promise with no delivery date, and a disabled
"Delete My Data" tells a user that control over their own health data exists and
that they may not have it. In a product whose whole argument is that the data is
theirs, that is a bad first thing to say. Privacy & Security arrives with #34 to
#37, Appearance with Batch 08, Premium with Batch 07.

ONE COPY OF THE FERTILITY DISCLAIMER

About needs it, and it was already a literal in TodayScreen and CalendarScreen —
a third copy would have made two versions of a safety promise inevitable. Now a
string resource all three read. Notifications still deliberately omit it: a long
sentence about fertility on a lock screen is the leak the discreet copy exists
to prevent.

The version is read from PackageManager rather than BuildConfig, so it reports
what is actually installed — the number a person reads out when something is
wrong.

A DOC TRIGGER THAT COULD NOT FIRE

docs/design/README.md's trigger is "any new user-facing screen or state" while
its Governs listed only docs/design/** and core/designsystem/**, so adding a
screen fired nothing. Same class of defect as the architecture doc's, found the
same way — by adding the thing the trigger is about and watching it not fire.
Widened, and verified by running doc-triggers.py against the new file.

Driven on PeriodMinSdk26: the root renders, rows expand in place, reminders open
as a child, and back returns to the root rather than exiting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-18 20:44:15 -05:00
parent 928680a61a
commit f43e1c2824
7 changed files with 257 additions and 20 deletions

View File

@ -40,11 +40,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
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
import dev.privacyllc.period.R
import dev.privacyllc.period.designsystem.PeriodTheme import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.designsystem.PeriodThemeDefaults import dev.privacyllc.period.designsystem.PeriodThemeDefaults
import dev.privacyllc.period.designsystem.art.ConfirmedPeriodMarker import dev.privacyllc.period.designsystem.art.ConfirmedPeriodMarker
@ -301,8 +303,7 @@ private fun Legend() {
} }
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
Text( Text(
"Fertility and ovulation dates are estimates based on cycle history and are not " + stringResource(R.string.fertility_disclaimer),
"intended to be used as contraception or as a medical diagnosis.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )

View File

@ -41,9 +41,10 @@ import java.time.format.DateTimeFormatter
/** /**
* Notification settings §28's privacy mode and §29's six toggles. * Notification settings §28's privacy mode and §29's six toggles.
* *
* The designed Settings screen is Batch 06; this is the working surface for the * A child of [SettingsScreen] rather than the Settings tab itself. It carried a
* plumbing, and it says so at the top for the same reason the Batch 01 Today * "Batch 05 · working surface" banner until the §36 tree existed to hold it;
* screen did: a convincing mock is how a screen comes to be believed finished. * the banner is gone because the claim it made is no longer true, not because
* the screen changed.
*/ */
@Composable @Composable
fun NotificationSettingsScreen(viewModel: NotificationSettingsViewModel = hiltViewModel()) { fun NotificationSettingsScreen(viewModel: NotificationSettingsViewModel = hiltViewModel()) {
@ -79,13 +80,6 @@ private fun NotificationSettingsContent(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp), .padding(16.dp),
) { ) {
Text(
"Batch 05 · working surface — the designed Settings screen is Batch 06",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Text("How reminders appear", style = MaterialTheme.typography.titleMedium) Text("How reminders appear", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
Text( Text(

View File

@ -0,0 +1,199 @@
package dev.privacyllc.period.feature.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import dev.privacyllc.period.R
import dev.privacyllc.period.designsystem.PeriodTheme
/**
* Settings, structured as PRODUCT_PLAN.md §36 specifies.
*
* ## Why sections appear as they are built
*
* §36 describes the whole tree Cycle, Notifications, Privacy & Security,
* Appearance, Premium, About. This screen shows only the sections that have
* something behind them, and **a row for an unbuilt feature is absent rather
* than disabled**.
*
* That is deliberate. A greyed-out "Export My Data" is a promise with no
* delivery date, and in a privacy app specifically it is worse than silence: a
* user who sees a disabled Delete My Data has been told the feature exists and
* that they may not have it. A section that is not there yet is simply not
* there yet, and the tracker is where the plan lives.
*
* So Privacy & Security arrives with #34 to #37, Appearance with Batch 08's
* theme control, and Premium with Batch 07.
*/
@Composable
fun SettingsScreen(onOpenNotifications: () -> Unit) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
SectionHeader("Notifications")
SettingsRow(
title = "Reminders and privacy",
subtitle = "What a reminder says, and what it withholds",
onClick = onOpenNotifications,
)
Spacer(Modifier.height(8.dp))
SectionHeader("About")
ExpandableRow(
title = "How predictions work",
body = "The app learns from the period dates you confirm. It weighs your " +
"recent cycles more heavily than old ones, and it measures how much " +
"they vary — that variation is what sets the window and the confidence " +
"label, so a steady cycle gets a tight window and an irregular one " +
"gets an honest wide one.\n\n" +
"It never presents a bare date as fact, and until there is enough of " +
"your own history it says so rather than falling back on a generic " +
"28-day cycle.",
)
ExpandableRow(
title = "Fertility disclaimer",
body = stringResource(R.string.fertility_disclaimer),
)
StaticRow(title = "Version", value = versionName())
}
}
/**
* The installed version, read from the package manager rather than a
* `BuildConfig` constant so it reports what is actually on the device, which
* is the number a person reads out when something is wrong.
*/
@Composable
private fun versionName(): String {
val context = LocalContext.current
return remember(context) {
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: "unknown"
}
}
@Composable
private fun SectionHeader(text: String) {
Spacer(Modifier.height(16.dp))
Text(
text,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(4.dp))
}
@Composable
private fun SettingsRow(title: String, subtitle: String, onClick: () -> Unit) {
Column(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(vertical = 12.dp),
) {
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider()
}
/**
* A row that opens its own explanation.
*
* Expanding in place rather than navigating: every one of these is a paragraph,
* and a destination that holds one paragraph is a back-stack entry the user has
* to spend to read two sentences.
*/
@Composable
private fun ExpandableRow(title: String, body: String) {
var open by remember { mutableStateOf(false) }
Column(
Modifier
.fillMaxWidth()
.clickable { open = !open }
.padding(vertical = 12.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(title, style = MaterialTheme.typography.bodyLarge)
// Text rather than a chevron: it states the action, and TalkBack
// reads it without needing a content description nobody wrote.
Text(
if (open) "Hide" else "Show",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
AnimatedVisibility(open) {
Text(
body,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
}
HorizontalDivider()
}
@Composable
private fun StaticRow(title: String, value: String) {
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(title, style = MaterialTheme.typography.bodyLarge)
Text(
value,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Preview(name = "Settings · light", showBackground = true)
@Preview(
name = "Settings · dark",
showBackground = true,
uiMode = android.content.res.Configuration.UI_MODE_NIGHT_YES,
)
@Composable
private fun PreviewSettingsRoot() = PeriodTheme {
SettingsScreen(onOpenNotifications = {})
}

View File

@ -34,11 +34,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
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
import dev.privacyllc.period.R
import dev.privacyllc.period.designsystem.PeriodTheme import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.designsystem.PeriodThemeDefaults import dev.privacyllc.period.designsystem.PeriodThemeDefaults
import dev.privacyllc.period.designsystem.art.CycleProgressMark import dev.privacyllc.period.designsystem.art.CycleProgressMark
@ -284,8 +286,7 @@ private fun FertilitySection(estimate: FertilityEstimate?, likelihood: Fertility
@Composable @Composable
private fun FertilityDisclaimer() { private fun FertilityDisclaimer() {
Text( Text(
"Fertility and ovulation dates are estimates based on cycle history and are not " + stringResource(R.string.fertility_disclaimer),
"intended to be used as contraception or as a medical diagnosis.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,

View File

@ -40,6 +40,7 @@ import dev.privacyllc.period.feature.calendar.CalendarScreen
import dev.privacyllc.period.feature.insights.InsightsScreen import dev.privacyllc.period.feature.insights.InsightsScreen
import dev.privacyllc.period.feature.onboarding.OnboardingScreen import dev.privacyllc.period.feature.onboarding.OnboardingScreen
import dev.privacyllc.period.feature.settings.NotificationSettingsScreen import dev.privacyllc.period.feature.settings.NotificationSettingsScreen
import dev.privacyllc.period.feature.settings.SettingsScreen
import dev.privacyllc.period.feature.today.TodayScreen import dev.privacyllc.period.feature.today.TodayScreen
/** /**
@ -137,10 +138,17 @@ fun PeriodApp() {
composable(PeriodDestination.CALENDAR.route) { CalendarScreen() } composable(PeriodDestination.CALENDAR.route) { CalendarScreen() }
composable(PeriodDestination.INSIGHTS.route) { InsightsScreen() } composable(PeriodDestination.INSIGHTS.route) { InsightsScreen() }
// Settings shows the notification plumbing from Batch 05. The // Settings is a root with children, so the tab lands on the §36
// designed screen — cycle, privacy and security, appearance, // tree and reminders live one level down. The tab item's own
// premium, about — is Batch 06, and this surface says so on itself. // navigate() pops back to this start destination, which is what
composable(PeriodDestination.SETTINGS.route) { NotificationSettingsScreen() } // makes tapping Settings from inside reminders return here rather
// than doing nothing.
composable(PeriodDestination.SETTINGS.route) {
SettingsScreen(
onOpenNotifications = { navController.navigate(SETTINGS_NOTIFICATIONS) },
)
}
composable(SETTINGS_NOTIFICATIONS) { NotificationSettingsScreen() }
} }
} }
} }
@ -181,3 +189,6 @@ private fun PlaceholderScreen(title: String) {
private fun PlaceholderPreview() { private fun PlaceholderPreview() {
PeriodTheme { PlaceholderScreen("Calendar") } PeriodTheme { PlaceholderScreen("Calendar") }
} }
/** Reminder settings, a child of the Settings tab rather than a fifth tab. */
private const val SETTINGS_NOTIFICATIONS = "settings/notifications"

View File

@ -17,4 +17,17 @@
<string name="tab_calendar">Calendar</string> <string name="tab_calendar">Calendar</string>
<string name="tab_insights">Insights</string> <string name="tab_insights">Insights</string>
<string name="tab_settings">Settings</string> <string name="tab_settings">Settings</string>
<!--
One copy, read by every surface that shows a fertility estimate.
§18 scopes this line to "near fertility features and in About", and it was
a literal in two screens before Settings needed a third. Two copies of a
safety disclaimer is how two versions of a promise come to exist — the
failure DOC_TRUST_MAP.md exists to prevent, in code rather than prose.
Notifications deliberately do NOT carry it: a long sentence about
fertility on a lock screen is the leak the discreet copy exists to avoid.
-->
<string name="fertility_disclaimer">Fertility and ovulation dates are estimates based on cycle history and are not intended to be used as contraception or as a medical diagnosis.</string>
</resources> </resources>

View File

@ -4,8 +4,9 @@
Status: Current Status: Current
Owner: _null Owner: _null
Last reviewed: 2026-08-18 Last reviewed: 2026-08-18
Governs: docs/design/**, core/designsystem/** — the design tokens — and the Governs: docs/design/**, core/designsystem/**, app/src/main/kotlin/**,
product's tone and interface copy app/src/main/res/values/** — the design tokens, every user-facing
screen, and the product's tone and interface copy
Review trigger: Any new user-facing screen or state; any change to the colour or Review trigger: Any new user-facing screen or state; any change to the colour or
type tokens; any change to notification copy or to a privacy or type tokens; any change to notification copy or to a privacy or
fertility disclaimer fertility disclaimer
@ -125,6 +126,23 @@ and it puts the failure in front of whoever is editing. And the `Surface` lives
in the theme rather than in each screen, so a screen without a `Scaffold` cannot in the theme rather than in each screen, so a screen without a `Scaffold` cannot
forget it. forget it.
## A row for an unbuilt feature is absent, not disabled
Settings is built as `PRODUCT_PLAN.md` §36 structures it, and it shows only the
sections that have something behind them. Privacy & Security arrives with the
issues that fill it; Appearance and Premium with their batches.
The alternative — every §36 row present and greyed out — is worse here than in
most apps. A disabled *Export My Data* is a promise with no delivery date, and a
disabled *Delete My Data* tells a user that the control over their own health
data exists and that they may not have it. In a product whose entire argument is
that the data is theirs, that is a bad first thing to say.
Absent is honest, and the tracker is where the plan lives — which is the same
rule [`../data/README.md`](../data/README.md) applies to placeholder images, for
the same reason: something that looks finished outlives the issue that would
have replaced it.
## The states most often left undesigned ## The states most often left undesigned
Designed here on purpose, because they are the two most people meet first: Designed here on purpose, because they are the two most people meet first: