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