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:
parent
928680a61a
commit
f43e1c2824
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = {})
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue