feat: two-tap period logging, and spotting that cannot reset the cycle

§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
This commit is contained in:
null 2026-08-18 03:56:52 -05:00
parent afd22dddfa
commit 035a9d4162
3 changed files with 311 additions and 3 deletions

View File

@ -14,13 +14,22 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card 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.MaterialTheme
import androidx.compose.material3.OutlinedButton 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.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.clearAndSetSemantics 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 * imprecise, and it says so and shows both forecasts rather than moving the
* goalposts quietly. * goalposts quietly.
*/ */
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TodayScreen(viewModel: TodayViewModel = hiltViewModel()) { fun TodayScreen(viewModel: TodayViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val message by viewModel.message.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( TodayContent(
state = state, state = state,
message = message, message = message,
onStarted = { viewModel.confirmStart(state.today) }, onStarted = viewModel::showLogSheet,
onNotYet = viewModel::notYet, onNotYet = viewModel::notYet,
onEndedToday = { onEndedToday = {
state.periods.maxByOrNull { it.startDate }?.let { viewModel.setEnd(it.id, state.today) } 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. */ /** Standard AdMob banner height, so Batch 07 changes what is inside and not the layout. */
private val BANNER_HEIGHT = 50.dp 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) { internal fun ConfidenceLabel.readable(): String = when (this) {

View File

@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.Clock 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 @HiltViewModel
class TodayViewModel @Inject constructor( class TodayViewModel @Inject constructor(
private val repository: CycleRepository, private val repository: CycleRepository,
@ -121,6 +133,18 @@ class TodayViewModel @Inject constructor(
initialValue = TodayUiState(today = LocalDate.now(clock)), 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<TodayPrompt?>(null)
val prompt: StateFlow<TodayPrompt?> = _prompt.asStateFlow()
fun dismissPrompt() { _prompt.value = null }
private val _message = MutableStateFlow<String?>(null) private val _message = MutableStateFlow<String?>(null)
/** A transient line for the user. Null when there is nothing to say. */ /** 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 { fun confirmStart(date: LocalDate) = write {
when (val r = repository.confirmPeriodStart(date)) { when (val r = repository.confirmPeriodStart(date)) {
is PeriodWriteResult.AlreadyRecorded -> _message.value = "That day is already logged." 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) } 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 { fun edit(id: Long, start: LocalDate, end: LocalDate?) = write {
when (repository.editPeriod(id, start, end)) { when (repository.editPeriod(id, start, end)) {
is PeriodWriteResult.Conflict -> is PeriodWriteResult.Conflict ->
@ -171,6 +206,27 @@ class TodayViewModel @Inject constructor(
fun setEnd(id: Long, end: LocalDate?) = write { fun setEnd(id: Long, end: LocalDate?) = write {
repository.setPeriodEnd(id, end) repository.setPeriodEnd(id, end)
_message.value = if (end == null) "Marked as still going." else "Updated ✓" _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<Long>()
/** 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) } fun delete(id: Long) = write { repository.deletePeriod(id) }

View File

@ -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)
}
}