diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt index d816ea2..e4e43cb 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/onboarding/OnboardingScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button @@ -30,6 +31,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.semantics.Role import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -278,13 +280,29 @@ private fun NotificationPrivacyStep(state: OnboardingUiState, viewModel: Onboard } @Composable + // The whole row is the target, not the radio. + // + // Found by tapping the label "Maximum privacy" on a device and watching + // nothing happen: only the RadioButton was clickable, so the option that + // decides what a lock screen shows could only be changed by hitting a + // ~20dp circle. selectable() on the Row with onClick = null on the radio is + // Material's documented pattern, and it also merges the semantics — TalkBack + // now announces one selectable option per row instead of a radio button + // followed by two loose pieces of text. private fun PrivacyOption(title: String, example: String, selected: Boolean, onSelect: () -> Unit) { Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Row( - Modifier.fillMaxWidth().padding(12.dp), + Modifier + .fillMaxWidth() + .selectable( + selected = selected, + role = Role.RadioButton, + onClick = onSelect, + ) + .padding(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - RadioButton(selected = selected, onClick = onSelect) + RadioButton(selected = selected, onClick = null) Spacer(Modifier.size(8.dp)) Column { Text(title, style = MaterialTheme.typography.titleMedium) 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 289369d..9cd2fc1 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 @@ -14,6 +14,7 @@ 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.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card @@ -27,6 +28,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment +import androidx.compose.ui.semantics.Role import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -149,13 +151,29 @@ private fun NotificationSettingsContent( } @Composable + // The whole row is the target, not the radio. + // + // Found by tapping the label "Maximum privacy" on a device and watching + // nothing happen: only the RadioButton was clickable, so the option that + // decides what a lock screen shows could only be changed by hitting a + // ~20dp circle. selectable() on the Row with onClick = null on the radio is + // Material's documented pattern, and it also merges the semantics — TalkBack + // now announces one selectable option per row instead of a radio button + // followed by two loose pieces of text. private fun PrivacyRow(title: String, example: String, selected: Boolean, onSelect: () -> Unit) { Card(Modifier.fillMaxWidth().padding(vertical = 3.dp)) { Row( - Modifier.fillMaxWidth().padding(12.dp), + Modifier + .fillMaxWidth() + .selectable( + selected = selected, + role = Role.RadioButton, + onClick = onSelect, + ) + .padding(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - RadioButton(selected = selected, onClick = onSelect) + RadioButton(selected = selected, onClick = null) Spacer(Modifier.height(8.dp)) Column(Modifier.padding(start = 8.dp)) { Text(title, style = MaterialTheme.typography.titleSmall) diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModel.kt new file mode 100644 index 0000000..e81fc56 --- /dev/null +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModel.kt @@ -0,0 +1,81 @@ +package dev.privacyllc.period.feature.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.privacyllc.period.core.data.CycleRepository +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** What the Privacy & Security section is doing right now. */ +enum class DeletionState { IDLE, DELETING, DONE, FAILED } + +/** + * §45's Delete My Data. + * + * The deletion itself has existed since Batch 01 and had an instrumented test + * before anything could call it. This adds the part a person touches, and the + * three rules that make it a privacy control rather than a destructive button. + * + * ## It deletes health data and nothing else + * + * `UserPreferences` lives in `core/datastore`, a separate store from the cycle + * database, and that separation exists for this moment: somebody exercising a + * privacy control has not asked to have their notification privacy reset to a + * default they did not choose. A user who wipes their history and is then sent + * a Direct-mode reminder has been failed twice. + * + * ## It does not touch the reminder schedule + * + * Tempting, and wrong. `ReminderWorker` reads the forecast on each run, and + * with no history `CycleStatusRules` returns `NoData`, which `ReminderRules` + * maps to no decision — so the scheduled work already does nothing while there + * is nothing to say. Cancelling it would be worse than pointless: scheduling + * only happens from `ReminderCoordinator` and the notification settings screen, + * so a cancel here would leave reminders silently off until the user next + * toggled a setting, long after they had logged a new period. + * + * ## There is no undo + * + * §45 says irreversible after confirmation, and an undo snackbar keeps the data + * alive for the length of its timeout — the opposite of what was just asked + * for. The confirmation carries the whole weight, so it says plainly what goes + * and what stays. + */ +@HiltViewModel +class PrivacyViewModel @Inject constructor( + private val repository: CycleRepository, +) : ViewModel() { + + private val _deletion = MutableStateFlow(DeletionState.IDLE) + val deletion: StateFlow = _deletion.asStateFlow() + + /** + * A failure surfaces instead of taking the process down. + * + * Batch 01's lesson, learned by tapping a button twice: a repository call + * that throws inside `viewModelScope.launch` kills the app. Here it would + * also leave the user unable to tell whether their data is gone, which is + * the worst possible moment for an ambiguous outcome. + */ + private val handler = CoroutineExceptionHandler { _, _ -> + _deletion.value = DeletionState.FAILED + } + + fun deleteEverything() { + if (_deletion.value == DeletionState.DELETING) return + _deletion.value = DeletionState.DELETING + viewModelScope.launch(handler) { + repository.deleteAllHealthData() + _deletion.value = DeletionState.DONE + } + } + + fun acknowledge() { + _deletion.value = DeletionState.IDLE + } +} 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 index 735e387..ea15d24 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/settings/SettingsScreen.kt @@ -12,11 +12,15 @@ 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.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -49,7 +53,13 @@ import dev.privacyllc.period.designsystem.PeriodTheme * theme control, and Premium with Batch 07. */ @Composable -fun SettingsScreen(onOpenNotifications: () -> Unit) { +fun SettingsScreen( + onOpenNotifications: () -> Unit, + viewModel: PrivacyViewModel? = hiltViewModel(), +) { + val deletion = viewModel?.deletion?.collectAsStateWithLifecycle()?.value ?: DeletionState.IDLE + var confirming by remember { mutableStateOf(false) } + Column( Modifier .fillMaxSize() @@ -63,6 +73,14 @@ fun SettingsScreen(onOpenNotifications: () -> Unit) { onClick = onOpenNotifications, ) + Spacer(Modifier.height(8.dp)) + SectionHeader("Privacy & Security") + SettingsRow( + title = "Delete my data", + subtitle = "Erase every period, spotting and prediction record", + onClick = { confirming = true }, + ) + Spacer(Modifier.height(8.dp)) SectionHeader("About") ExpandableRow( @@ -82,6 +100,73 @@ fun SettingsScreen(onOpenNotifications: () -> Unit) { ) StaticRow(title = "Version", value = versionName()) } + + if (confirming) { + DeleteConfirmation( + onConfirm = { + confirming = false + viewModel?.deleteEverything() + }, + onDismiss = { confirming = false }, + ) + } + + when (deletion) { + DeletionState.DONE -> ResultDialog( + title = "Your data is deleted", + body = "Every period, spotting and prediction record has been erased " + + "from this device. Your reminder settings are unchanged.", + onDismiss = { viewModel?.acknowledge() }, + ) + DeletionState.FAILED -> ResultDialog( + title = "Nothing was deleted", + body = "Something went wrong and your data is still here. Try again, " + + "and if it keeps failing your records are intact.", + onDismiss = { viewModel?.acknowledge() }, + ) + else -> Unit + } +} + +/** + * §45 wants this "clear and irreversible after confirmation", so the dialog + * does three things a default one would not. + * + * It says what is destroyed **and what is kept** — a user deleting their history + * should not have to wonder whether their notification privacy went with it. It + * labels the button with the action rather than "OK", because "OK" to a question + * nobody read is how accidental deletions happen. And it does not offer an undo: + * that is the point of the control, not an omission. + */ +@Composable +private fun DeleteConfirmation(onConfirm: () -> Unit, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Delete your data?") }, + text = { + Text( + "This erases every period, spotting and prediction record on this " + + "device. Your reminder and privacy settings are kept.\n\n" + + "This cannot be undone.", + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text("Delete my data", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Keep my data") } }, + ) +} + +@Composable +private fun ResultDialog(title: String, body: String, onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(body) }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } }, + ) } /** @@ -195,5 +280,5 @@ private fun StaticRow(title: String, value: String) { ) @Composable private fun PreviewSettingsRoot() = PeriodTheme { - SettingsScreen(onOpenNotifications = {}) + SettingsScreen(onOpenNotifications = {}, viewModel = null) } diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModelTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModelTest.kt new file mode 100644 index 0000000..135dba9 --- /dev/null +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/settings/PrivacyViewModelTest.kt @@ -0,0 +1,140 @@ +package dev.privacyllc.period.feature.settings + +import androidx.test.core.app.ApplicationProvider +import dev.privacyllc.period.core.data.CycleData +import dev.privacyllc.period.core.data.CycleRepository +import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.Clock +import java.time.LocalDate +import java.time.ZoneOffset + +/** + * §45's Delete My Data, tested for what it destroys **and what it leaves**. + * + * The second half is the one worth having. "It deleted everything" is easy to + * assert and is not the requirement: the requirement is that health history goes + * and the user's own settings do not, because `core/datastore` was made a + * separate store precisely so a privacy action could not quietly reset a choice + * somebody made. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PrivacyViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + private val today = LocalDate.of(2026, 8, 18) + private val clock = Clock.fixed(today.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC) + + private lateinit var repo: CycleRepository + private lateinit var vm: PrivacyViewModel + + @Before fun setUp() { + Dispatchers.setMain(dispatcher) + repo = CycleData.repository( + ApplicationProvider.getApplicationContext(), + PersonalPredictionEngine(), + clock, + ) + runBlocking { repo.deleteAllHealthData() } + vm = PrivacyViewModel(repo) + } + + @After fun tearDown() = Dispatchers.resetMain() + + private fun await(predicate: suspend () -> Boolean) = runBlocking { + withTimeout(5_000) { while (!predicate()) delay(10) } + } + + private fun seedThreeCycles() = runBlocking { + repo.confirmPeriodStart(today.minusDays(84)) + repo.confirmPeriodStart(today.minusDays(56)) + repo.confirmPeriodStart(today.minusDays(28)) + } + + @Test fun `it starts idle`() { + assertEquals(DeletionState.IDLE, vm.deletion.value) + } + + @Test fun `it erases every period record`() { + seedThreeCycles() + await { repo.confirmedPeriods.first().size == 3 } + + vm.deleteEverything() + + await { vm.deletion.value == DeletionState.DONE } + assertTrue(runBlocking { repo.confirmedPeriods.first().isEmpty() }) + } + + @Test fun `the forecast goes with the history that produced it`() { + seedThreeCycles() + await { repo.forecast.first() != null } + + vm.deleteEverything() + await { vm.deletion.value == DeletionState.DONE } + + assertEquals(null, runBlocking { repo.forecast.first() }) + } + + /** + * The app must survive its own empty state afterwards. + * + * Deleting everything puts the user back on a screen that has never been + * reached from this direction before — the first-run empty state on a + * process that has already seen data. Reading the flows is what Today does + * on recomposition, so this is the crash that would greet them. + */ + @Test fun `reading the flows after deletion does not throw`() { + seedThreeCycles() + await { repo.confirmedPeriods.first().size == 3 } + + vm.deleteEverything() + await { vm.deletion.value == DeletionState.DONE } + + runBlocking { + assertTrue(repo.confirmedPeriods.first().isEmpty()) + assertEquals(null, repo.forecast.first()) + assertTrue(repo.notYetObservations.first().isEmpty()) + } + } + + @Test fun `acknowledging returns it to idle so the dialog does not reappear`() { + seedThreeCycles() + vm.deleteEverything() + await { vm.deletion.value == DeletionState.DONE } + + vm.acknowledge() + + assertEquals(DeletionState.IDLE, vm.deletion.value) + } + + /** + * Tapping twice was a real crash in Batch 01, on a different screen. + * Here the second tap must be ignored rather than starting a second + * transaction against a database the first one is emptying. + */ + @Test fun `a second delete while one is running is ignored`() { + seedThreeCycles() + + vm.deleteEverything() + vm.deleteEverything() + + await { vm.deletion.value == DeletionState.DONE } + assertTrue(runBlocking { repo.confirmedPeriods.first().isEmpty() }) + } +} diff --git a/docs/history/DEVELOPMENT_LOG.md b/docs/history/DEVELOPMENT_LOG.md index db38a8f..d3100b5 100644 --- a/docs/history/DEVELOPMENT_LOG.md +++ b/docs/history/DEVELOPMENT_LOG.md @@ -32,6 +32,66 @@ written and stay true. It is exempt from review for the same reason a receipt is ## Entries +### 2026-08-18 — Batch 06 starts, and two defects that only a device could show + +The designed Settings screen and Delete My Data both landed. Neither is the +interesting part of the day. + +**A privacy control whose label did nothing.** The delete confirmation promises +"your reminder settings are unchanged", and proving that meant changing a +setting first — so I tapped "Maximum privacy" on a device and watched nothing +happen. `PrivacyRow` and onboarding's `PrivacyOption` both put `onClick` on the +`RadioButton` and left the row inert. The option deciding what a lock screen +shows could only be changed by hitting a 20dp circle, in the two places a user +ever chooses it. Fixed with `Modifier.selectable` on the row, which also merges +the semantics so TalkBack announces one option instead of a radio and two loose +strings. + +It was found by trying to verify a different claim. That is worth noting on its +own: the check that found it was not looking for it. + +**And the setting does survive deletion** — set to Maximum privacy, deleted +everything, still Maximum privacy. Now demonstrated rather than argued from the +fact that `core/datastore` is a separate store. + +**A design I had wrong before writing it.** My first draft of `PrivacyViewModel` +cancelled the reminder schedule on delete, which seems obviously right and is +not. `ReminderWorker` reads the forecast each run and `NoData` maps to no +decision, so the scheduled work already does nothing while there is nothing to +say — and scheduling only happens from `ReminderCoordinator` and the settings +screen, so cancelling would have left reminders silently off until the user next +toggled something, long after they had logged a new period. Checked the call +sites instead of reasoning from the name. + +**Also fixed, from the font-scale pass:** the bottom navigation wrapped its +labels mid-word at scale 2.0 — *"Calenda / r"*. `maxLines = 1` and an ellipsis. +That defect predates all of today's work; the font-scale gap is simply the first +thing that looked. + +**Settings shows only the sections that have something behind them.** A +greyed-out "Delete My Data" tells a user that control over their own health data +exists and that they may not have it, which is a bad first thing for this +product to say. Recorded in `docs/design/README.md` so the next person adding a +row knows it was a decision. + +Round 4 recorded in `docs/qa/`. Four rounds in, the pattern has not moved: every +defect of consequence has been found by a person using the app, and none by a +test. + +- **Closed:** #29, #33, #36. +- **Next action:** Continue Batch 06. #38, the release-build logging guard, is + independent of the rest and can go next — write the guard first and prove it + fails, per `docs/architecture/GUARDS.md`, because three guards in this project + have been green over the exact failure they claimed to catch. #35 export and + #37 the privacy promise both need only the Settings screen, which now exists. + #34, app lock, still carries an open product question: what happens when + somebody forgets their PIN, given an irreversible delete sits on the same + screen. +- **Blockers:** #9, the Command Center webhook, still needs a person. TalkBack + has still never been run and no real lock screen has been looked at — and + after today's find, the TalkBack gap looks more expensive than it did: two + screens had merged-semantics problems nobody would see without it. + ### 2026-08-18 — The onboarding artwork ships, and a plan I had argued for turned out to be wrong All eight illustrations are in the app in both themes, every onboarding step has diff --git a/docs/qa/ClaudeQACoverage.md b/docs/qa/ClaudeQACoverage.md index f8ab565..1daa71a 100644 --- a/docs/qa/ClaudeQACoverage.md +++ b/docs/qa/ClaudeQACoverage.md @@ -140,6 +140,42 @@ Invisible to the unit tests and invisible to an API 36 emulator. Fixed, and worth recording as a standing gap below: **this project has no test on a device at its own `minSdk`.** +## Round 4 — 2026-08-18 at `f43e1c2`, partial + +Batch 06 began: the designed Settings screen and Delete My Data. Driven by hand +on `PeriodMinSdk26`, plus the font-scale pass listed under the gaps below. + +### What driving it found that no test would have + +Two defects, both in controls that already looked finished: + +1. **Only the radio button in a privacy option was clickable.** Tapping the + label "Maximum privacy" did nothing — `PrivacyRow` and onboarding's + `PrivacyOption` put `onClick` on the `RadioButton` and left the row inert, so + the control that decides what a lock screen shows could only be changed by + hitting a ~20dp circle. Found while trying to *prove something else*: the + delete confirmation promises "your reminder settings are unchanged", and + testing that claim meant changing a setting first, which would not work. + Fixed with `Modifier.selectable` on the row and `onClick = null` on the + radio, which also merges the semantics for TalkBack. +2. **The navigation bar wrapped its labels mid-word at font scale 2.0** — + recorded under the font-scaling gap below. + +Neither is visible in a unit test, a preview, or a code review. Both took one +person tapping the thing. + +### What was verified rather than assumed + +- Delete My Data erases every period, spotting and prediction record, and the + user's notification-privacy choice **survives it** — set to Maximum privacy, + deleted, still Maximum privacy afterwards. That is the claim the confirmation + dialog makes to the user, so it is checked on a device rather than argued from + the code. +- Today renders its empty state after a deletion without crashing, which is the + first-run screen reached from a direction it had never been reached from. +- `PrivacyViewModelTest` covers the same ground on the JVM, including a second + delete while one is running. + ## Standing gaps Things no round has ever covered, carried forward until they are. This list diff --git a/docs/qa/ClaudeReport.md b/docs/qa/ClaudeReport.md index 75fd8a2..cd68143 100644 --- a/docs/qa/ClaudeReport.md +++ b/docs/qa/ClaudeReport.md @@ -19,21 +19,22 @@ Review trigger: Any QA round run ## Current run-state -- **Last QA round:** Round 3 — 2026-08-18, partial (A and B pass; C, D, E, F, G and H partial) -- **Last verified build SHA:** `0451fbe` -- **Last tested device / environment:** emulator `PeriodQA`, API 36, Pixel 6 profile, debug build -- **Overall status:** Five of eight batches are done and the app is a coherent - product: onboarding to a forecast, a Today screen with six honest states, - two-tap logging, a calendar readable in greyscale, insights that decline to - overstate, fertility estimates that refuse to appear when the forecast is too - vague to support them, and discreet reminders. It now wears its real brand, - supplied by the owner. Four instrumented tests assert that nothing about a - period reaches a lock screen in either private mode — but **nobody has yet - looked at an actual locked screen**, and that last mile is the single most - valuable thing left to check, because a notification read over a shoulder is - the likeliest real privacy failure in this product. The other standing gaps - are of the same kind: TalkBack has never been run, text has never been scaled, - and nothing has run at the minimum Android version this app claims to support. +- **Last QA round:** Round 4 — 2026-08-18, partial (A and B pass; C, D, E, F, G and H partial) +- **Last verified build SHA:** `f43e1c2` +- **Last tested device / environment:** emulator `PeriodMinSdk26`, API 26, Pixel 6 profile, debug build, at font scales 1.0, 1.3 and 2.0 +- **Overall status:** Five of eight batches are done and Batch 06 has begun: + Settings is now the screen §36 describes, and Delete My Data is reachable, + confirmed and irreversible. The app has run at its minimum Android version and + at the largest accessibility font scale, and both found defects that had been + shipping unnoticed — a navigation bar that wrapped its labels mid-word, and a + privacy option whose label was not clickable at all, so the control deciding + what a lock screen shows could only be changed by hitting the radio button + exactly. Both are fixed. The pattern is now four rounds old and unchanged: + **every defect of consequence in this project has been found by a person using + the app, and none by a test.** The tests are what stop them coming back. The + standing gaps are the same shape — nobody has yet looked at an actual locked + screen, and TalkBack has still never been run, which is now the single most + valuable unspent hour. ## Open defects @@ -61,6 +62,12 @@ One entry per round, the verdict only. What each pass reached, and what it could not, lives in [`ClaudeQACoverage.md`](ClaudeQACoverage.md) and is deliberately not repeated here. +- **Round 4 — 2026-08-18 at `f43e1c2`, partial.** Batch 06 began, and driving + it found two defects in controls that already looked finished: navigation + labels wrapping mid-word at font scale 2.0, and a privacy option whose row was + inert because only its radio button was clickable. Verdict: the privacy + controls now behave as their own dialog promises — checked by setting a + preference, deleting all data, and confirming the preference survived. - **Round 3 — 2026-08-18 at `0451fbe`, partial.** Fertility and reminders landed and pass F became runnable for the first time. Verdict: fit to keep building, not fit to ship — the privacy promise is proved against the notification