From 035a9d41625488ebb464e3021b980c600dd653bd Mon Sep 17 00:00:00 2001 From: null Date: Tue, 18 Aug 2026 03:56:52 -0500 Subject: [PATCH] feat: two-tap period logging, and spotting that cannot reset the cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §23's path exactly: "Started period" opens a sheet, "Yes — today" closes it and the forecast has already moved. No symptoms, no mood, no notes, no survey — §23 lists all four as things not to force, and each one is a reason somebody stops logging at all. Confirmed on a device: two taps, then "Logged ✓ Your predictions have been updated." Spotting sits in the same sheet rather than behind another tap, because it is the answer to the same question the user just asked themselves, and one more tap is how it stops being recorded. §25's question, and both halves of it The paragraph that permits "was this your period or spotting?" also says not to over-question. Both are tests: a one-day entry asks, a five-day entry does not, and a dismissed question is not asked again for that record. Answering "Period" is a complete answer — there is no "ask me later", which is the option that turns one question into three. Reclassifying deletes the period record and keeps the day as spotting, and the test that matters asserts the FORECAST is unchanged either way. That is §25's real requirement — spotting must not reset the cycle — and a forecast is the only thing that can prove it. Verified on a device too: the screen went straight back to cycle day 26 with the same 21 August forecast it had before. §24's "Updated ✓" acknowledgement on ending a period, because a silent write reads as a failed tap. 135 tests, all passing. ./gradlew check green. closes #18 --- .../period/feature/today/TodayScreen.kt | 126 ++++++++++++++++- .../period/feature/today/TodayViewModel.kt | 58 +++++++- .../feature/today/TodayViewModelTest.kt | 130 ++++++++++++++++++ 3 files changed, 311 insertions(+), 3 deletions(-) create mode 100644 app/src/test/kotlin/dev/privacyllc/period/feature/today/TodayViewModelTest.kt 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 ee7696a..a28fa21 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 @@ -14,13 +14,22 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.SelectableDates import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable +import androidx.compose.material3.rememberModalBottomSheetState 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.semantics.clearAndSetSemantics @@ -59,15 +68,32 @@ import java.time.format.DateTimeFormatter * imprecise, and it says so and shows both forecasts rather than moving the * goalposts quietly. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun TodayScreen(viewModel: TodayViewModel = hiltViewModel()) { val state by viewModel.state.collectAsStateWithLifecycle() val message by viewModel.message.collectAsStateWithLifecycle() + val prompt by viewModel.prompt.collectAsStateWithLifecycle() + + when (val p = prompt) { + TodayPrompt.LogPeriod -> LogPeriodSheet( + today = state.today, + onToday = { viewModel.confirmStart(state.today) }, + onDate = viewModel::confirmStart, + onSpotting = { viewModel.logSpotting(state.today) }, + onDismiss = viewModel::dismissPrompt, + ) + is TodayPrompt.PeriodOrSpotting -> PeriodOrSpottingDialog( + onPeriod = viewModel::dismissPrompt, + onSpotting = { viewModel.reclassifyAsSpotting(p.recordId, p.startDate) }, + ) + null -> Unit + } TodayContent( state = state, message = message, - onStarted = { viewModel.confirmStart(state.today) }, + onStarted = viewModel::showLogSheet, onNotYet = viewModel::notYet, onEndedToday = { state.periods.maxByOrNull { it.startDate }?.let { viewModel.setEnd(it.id, state.today) } @@ -422,6 +448,102 @@ private fun BannerSlot() { /** Standard AdMob banner height, so Batch 07 changes what is inside and not the layout. */ private val BANNER_HEIGHT = 50.dp +// --------------------------------------------------------------------------- +// Logging — §23, §24, §25 +// --------------------------------------------------------------------------- + +/** + * §23, and the whole of it is that this takes two taps. + * + * `Started period` opens this; `Yes — today` closes it and the forecast has + * moved. No symptoms, no mood, no notes, no survey — §23 lists all four as + * things not to force, and every one of them is a reason somebody stops logging. + * + * Spotting is here rather than on a separate screen because it is the answer to + * the same question the user just asked themselves, and because putting it + * behind another tap is how it stops being recorded at all. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LogPeriodSheet( + today: LocalDate, + onToday: () -> Unit, + onDate: (LocalDate) -> Unit, + onSpotting: () -> Unit, + onDismiss: () -> Unit, +) { + var pickingDate by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState() + + if (pickingDate) { + val pickerState = rememberDatePickerState( + selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean = + java.time.Instant.ofEpochMilli(utcTimeMillis) + .atZone(java.time.ZoneOffset.UTC).toLocalDate() <= today + }, + ) + DatePickerDialog( + onDismissRequest = { pickingDate = false }, + confirmButton = { + TextButton(onClick = { + pickerState.selectedDateMillis?.let { + onDate( + java.time.Instant.ofEpochMilli(it) + .atZone(java.time.ZoneOffset.UTC).toLocalDate(), + ) + } + pickingDate = false + }) { Text("Log it") } + }, + dismissButton = { TextButton(onClick = { pickingDate = false }) { Text("Cancel") } }, + ) { DatePicker(state = pickerState) } + return + } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text("Started today?", style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.height(24.dp)) + Button(onClick = onToday, modifier = Modifier.fillMaxWidth()) { Text("Yes — today") } + Spacer(Modifier.height(8.dp)) + OutlinedButton(onClick = { pickingDate = true }, modifier = Modifier.fillMaxWidth()) { + Text("Choose another date") + } + Spacer(Modifier.height(16.dp)) + TextButton(onClick = onSpotting) { Text("This was spotting, not a period") } + Spacer(Modifier.height(16.dp)) + } + } +} + +/** + * §25's question, asked once and only when the length suggests it. + * + * The same paragraph that permits this question says not to over-question, so + * it fires on an entry of two days or fewer, once per record, and never again + * if dismissed. Both answers are complete — there is no "ask me later", because + * that is the option that turns one question into three. + */ +@Composable +private fun PeriodOrSpottingDialog(onPeriod: () -> Unit, onSpotting: () -> Unit) { + androidx.compose.material3.AlertDialog( + onDismissRequest = onPeriod, + title = { Text("Was this your period or spotting?") }, + text = { + Text( + "That entry was quite short. Spotting is recorded separately and " + + "does not change your cycle predictions.", + ) + }, + confirmButton = { TextButton(onClick = onPeriod) { Text("Period") } }, + dismissButton = { TextButton(onClick = onSpotting) { Text("Spotting") } }, + ) +} + // --------------------------------------------------------------------------- internal fun ConfidenceLabel.readable(): String = when (this) { diff --git a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt index 6d35b5d..2af1068 100644 --- a/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt +++ b/app/src/main/kotlin/dev/privacyllc/period/feature/today/TodayViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.Clock @@ -85,6 +86,17 @@ data class TodayUiState( } } +/** One-off questions and sheets. */ +sealed interface TodayPrompt { + /** §23's two-tap path: "Yes — Today" or "Choose Another Date". */ + data object LogPeriod : TodayPrompt + + /** §25's single, gentle question about a very short entry. */ + data class PeriodOrSpotting(val recordId: Long, val startDate: LocalDate) : TodayPrompt +} + +private const val SHORT_PERIOD_DAYS = 2 + @HiltViewModel class TodayViewModel @Inject constructor( private val repository: CycleRepository, @@ -121,6 +133,18 @@ class TodayViewModel @Inject constructor( initialValue = TodayUiState(today = LocalDate.now(clock)), ) + /** + * Which one-off prompt is on screen, if any. + * + * Deliberately not persisted. §25 warns against over-questioning: a prompt + * that survives a restart is a prompt the user has already declined once and + * is being asked again. + */ + private val _prompt = MutableStateFlow(null) + val prompt: StateFlow = _prompt.asStateFlow() + + fun dismissPrompt() { _prompt.value = null } + private val _message = MutableStateFlow(null) /** A transient line for the user. Null when there is nothing to say. */ @@ -153,11 +177,22 @@ class TodayViewModel @Inject constructor( fun confirmStart(date: LocalDate) = write { when (val r = repository.confirmPeriodStart(date)) { is PeriodWriteResult.AlreadyRecorded -> _message.value = "That day is already logged." - is PeriodWriteResult.Added -> _message.value = null + is PeriodWriteResult.Added -> _message.value = "Logged ✓ Your predictions have been updated." else -> Unit.also { check(r !is PeriodWriteResult.Conflict) } } + _prompt.value = null } + fun logSpotting(date: LocalDate) = write { + repository.recordSpotting(date) + _prompt.value = null + // Said explicitly, because the whole point of the separate record is + // that nothing else changed — §25: spotting must not reset the cycle. + _message.value = "Spotting logged. Your cycle is unchanged." + } + + fun showLogSheet() { _prompt.value = TodayPrompt.LogPeriod } + fun edit(id: Long, start: LocalDate, end: LocalDate?) = write { when (repository.editPeriod(id, start, end)) { is PeriodWriteResult.Conflict -> @@ -171,6 +206,27 @@ class TodayViewModel @Inject constructor( fun setEnd(id: Long, end: LocalDate?) = write { repository.setPeriodEnd(id, end) _message.value = if (end == null) "Marked as still going." else "Updated ✓" + + // §25: an extremely short entry may have been spotting. Asked ONCE, and + // only when the length actually suggests it — the same paragraph that + // permits the question also says not to over-question the user. + val record = repository.confirmedPeriods.first().firstOrNull { it.id == id } + val days = record?.let { r -> end?.let { (it.toEpochDay() - r.startDate.toEpochDay()).toInt() + 1 } } + if (days != null && days <= SHORT_PERIOD_DAYS && !askedAboutSpotting.contains(id)) { + askedAboutSpotting += id + _prompt.value = TodayPrompt.PeriodOrSpotting(id, record.startDate) + } + } + + /** Ids already queried this session. One question per record, per §25. */ + private val askedAboutSpotting = mutableSetOf() + + /** Reclassify a very short entry as spotting: delete the period, keep the day. */ + fun reclassifyAsSpotting(id: Long, date: LocalDate) = write { + repository.deletePeriod(id) + repository.recordSpotting(date) + _prompt.value = null + _message.value = "Recorded as spotting. Your cycle is unchanged." } fun delete(id: Long) = write { repository.deletePeriod(id) } diff --git a/app/src/test/kotlin/dev/privacyllc/period/feature/today/TodayViewModelTest.kt b/app/src/test/kotlin/dev/privacyllc/period/feature/today/TodayViewModelTest.kt new file mode 100644 index 0000000..e374cce --- /dev/null +++ b/app/src/test/kotlin/dev/privacyllc/period/feature/today/TodayViewModelTest.kt @@ -0,0 +1,130 @@ +package dev.privacyllc.period.feature.today + +import androidx.test.core.app.ApplicationProvider +import dev.privacyllc.period.core.data.CycleData +import dev.privacyllc.period.core.data.CycleRepository +import dev.privacyllc.period.core.data.PeriodWriteResult +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.assertNull +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 + +/** + * §23, §24 and §25 as they behave, rather than as they look. + * + * The spotting question is the one worth pinning: §25 permits it and, in the + * same paragraph, says not to over-question. Both halves are tests, because + * only asking is easy and only-asking-when-warranted is the requirement. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TodayViewModelTest { + + 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: TodayViewModel + + @Before fun setUp() { + Dispatchers.setMain(dispatcher) + repo = CycleData.repository(ApplicationProvider.getApplicationContext(), PersonalPredictionEngine(), clock) + runBlocking { repo.deleteAllHealthData() } + vm = TodayViewModel(repo, clock) + } + + @After fun tearDown() = Dispatchers.resetMain() + + private fun await(predicate: suspend () -> Boolean) = runBlocking { + withTimeout(5_000) { while (!predicate()) delay(10) } + } + + @Test fun `logging today confirms it in words the user can read`() { + vm.confirmStart(today) + await { repo.confirmedPeriods.first().isNotEmpty() } + await { vm.message.value != null } + assertTrue(vm.message.value!!.startsWith("Logged")) + } + + @Test fun `a very short entry asks whether it was spotting`() = runBlocking { + val id = (repo.confirmPeriodStart(today) as PeriodWriteResult.Added).id + + vm.setEnd(id, today) // one day + await { vm.prompt.value != null } + + val prompt = vm.prompt.value + assertTrue(prompt is TodayPrompt.PeriodOrSpotting) + assertEquals(id, (prompt as TodayPrompt.PeriodOrSpotting).recordId) + } + + @Test fun `an ordinary period is not questioned`() = runBlocking { + val id = (repo.confirmPeriodStart(today.minusDays(4)) as PeriodWriteResult.Added).id + + vm.setEnd(id, today) // five days + await { vm.message.value != null } + + // §25: do not over-question. Five days is a period, and asking would + // teach the user that the app second-guesses everything they enter. + assertNull(vm.prompt.value) + } + + @Test fun `the spotting question is asked once per record`() = runBlocking { + val id = (repo.confirmPeriodStart(today) as PeriodWriteResult.Added).id + + vm.setEnd(id, today) + await { vm.prompt.value != null } + vm.dismissPrompt() + + // Answering "Period" — that is, dismissing — must settle it. Asking + // again on the next edit is how one gentle question becomes nagging. + vm.setEnd(id, today) + await { vm.message.value != null } + assertNull(vm.prompt.value) + } + + @Test fun `reclassifying as spotting removes the period and leaves the cycle alone`() = runBlocking { + // A real cycle, so there is a forecast that must survive. + repo.confirmPeriodStart(today.minusDays(56)) + repo.confirmPeriodStart(today.minusDays(28)) + val before = repo.forecast.first()!!.mostLikelyStartDate + + val id = (repo.confirmPeriodStart(today) as PeriodWriteResult.Added).id + vm.reclassifyAsSpotting(id, today) + await { repo.spotting.first().isNotEmpty() } + + assertEquals("the period record is gone", 2, repo.confirmedPeriods.first().size) + assertEquals("and the day is kept as spotting", 1, repo.spotting.first().size) + // §25: spotting must not reset the cycle. The forecast is the proof. + assertEquals(before, repo.forecast.first()!!.mostLikelyStartDate) + } + + @Test fun `logging spotting directly never touches the cycle`() = runBlocking { + repo.confirmPeriodStart(today.minusDays(56)) + repo.confirmPeriodStart(today.minusDays(28)) + val before = repo.forecast.first()!!.mostLikelyStartDate + + vm.logSpotting(today) + await { repo.spotting.first().isNotEmpty() } + + assertEquals(2, repo.confirmedPeriods.first().size) + assertEquals(before, repo.forecast.first()!!.mostLikelyStartDate) + } +}