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.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.privacyllc.period.R
import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.designsystem.PeriodThemeDefaults
import dev.privacyllc.period.designsystem.art.ConfirmedPeriodMarker
@ -301,8 +303,7 @@ private fun Legend() {
}
Spacer(Modifier.height(8.dp))
Text(
"Fertility and ovulation dates are estimates based on cycle history and are not " +
"intended to be used as contraception or as a medical diagnosis.",
stringResource(R.string.fertility_disclaimer),
style = MaterialTheme.typography.bodySmall,
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.
*
* The designed Settings screen is Batch 06; this is the working surface for the
* plumbing, and it says so at the top for the same reason the Batch 01 Today
* screen did: a convincing mock is how a screen comes to be believed finished.
* A child of [SettingsScreen] rather than the Settings tab itself. It carried a
* "Batch 05 · working surface" banner until the §36 tree existed to hold it;
* the banner is gone because the claim it made is no longer true, not because
* the screen changed.
*/
@Composable
fun NotificationSettingsScreen(viewModel: NotificationSettingsViewModel = hiltViewModel()) {
@ -79,13 +80,6 @@ private fun NotificationSettingsContent(
.verticalScroll(rememberScrollState())
.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)
Spacer(Modifier.height(4.dp))
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.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.privacyllc.period.R
import dev.privacyllc.period.designsystem.PeriodTheme
import dev.privacyllc.period.designsystem.PeriodThemeDefaults
import dev.privacyllc.period.designsystem.art.CycleProgressMark
@ -284,8 +286,7 @@ private fun FertilitySection(estimate: FertilityEstimate?, likelihood: Fertility
@Composable
private fun FertilityDisclaimer() {
Text(
"Fertility and ovulation dates are estimates based on cycle history and are not " +
"intended to be used as contraception or as a medical diagnosis.",
stringResource(R.string.fertility_disclaimer),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
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.onboarding.OnboardingScreen
import dev.privacyllc.period.feature.settings.NotificationSettingsScreen
import dev.privacyllc.period.feature.settings.SettingsScreen
import dev.privacyllc.period.feature.today.TodayScreen
/**
@ -137,10 +138,17 @@ fun PeriodApp() {
composable(PeriodDestination.CALENDAR.route) { CalendarScreen() }
composable(PeriodDestination.INSIGHTS.route) { InsightsScreen() }
// Settings shows the notification plumbing from Batch 05. The
// designed screen — cycle, privacy and security, appearance,
// premium, about — is Batch 06, and this surface says so on itself.
composable(PeriodDestination.SETTINGS.route) { NotificationSettingsScreen() }
// Settings is a root with children, so the tab lands on the §36
// tree and reminders live one level down. The tab item's own
// navigate() pops back to this start destination, which is what
// 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() {
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_insights">Insights</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>

View File

@ -4,8 +4,9 @@
Status: Current
Owner: _null
Last reviewed: 2026-08-18
Governs: docs/design/**, core/designsystem/** — the design tokens — and the
product's tone and interface copy
Governs: docs/design/**, core/designsystem/**, app/src/main/kotlin/**,
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
type tokens; any change to notification copy or to a privacy or
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
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
Designed here on purpose, because they are the two most people meet first: