diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt
index d9a9069..ad9c3d1 100644
--- a/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt
+++ b/app/src/main/kotlin/dev/privacyllc/period/feature/calendar/CalendarScreen.kt
@@ -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,
)
diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/NotificationSettingsScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/NotificationSettingsScreen.kt
index c7eea8c..289369d 100644
--- a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/NotificationSettingsScreen.kt
+++ b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/NotificationSettingsScreen.kt
@@ -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(
diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt
new file mode 100644
index 0000000..735e387
--- /dev/null
+++ b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt
@@ -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 = {})
+}
diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt
index 54fdf84..3c64d21 100644
--- a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt
+++ b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayScreen.kt
@@ -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,
diff --git a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt
index 3180598..a0d8425 100644
--- a/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt
+++ b/app/src/main/kotlin/dev/privacyllc/period/navigation/PeriodApp.kt
@@ -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"
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 31c7719..5df008b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -17,4 +17,17 @@
Calendar
Insights
Settings
+
+
+ Fertility and ovulation dates are estimates based on cycle history and are not intended to be used as contraception or as a medical diagnosis.
diff --git a/docs/design/README.md b/docs/design/README.md
index caf2781..a2dcd70 100644
--- a/docs/design/README.md
+++ b/docs/design/README.md
@@ -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: