From f43e1c2824e1017937a18a17df69c6dc8d86b11f Mon Sep 17 00:00:00 2001 From: null Date: Tue, 18 Aug 2026 20:44:15 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20the=20designed=20Settings=20screen,=20s?= =?UTF-8?q?tructured=20as=20=C2=A736=20specifies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../period/feature/calendar/CalendarScreen.kt | 5 +- .../settings/NotificationSettingsScreen.kt | 14 +- .../period/feature/settings/SettingsScreen.kt | 199 ++++++++++++++++++ .../period/feature/today/TodayScreen.kt | 5 +- .../privacyllc/period/navigation/PeriodApp.kt | 19 +- app/src/main/res/values/strings.xml | 13 ++ docs/design/README.md | 22 +- 7 files changed, 257 insertions(+), 20 deletions(-) create mode 100644 app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt 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: